4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

つまづき:Prism mock serverとaspidaをJest, aspida-mockでテストしてみた

Posted at

はじめに

初めてRecoilを使用するにあたってAsynchronous Data Queriesで非同期通信(aspida, axios)で行なっているSelectorがあり、それを呼び出しているコンポーネントのレンダリングテストをする時につまづいた出来事です。

今回はaspida-mockを使用してAPIを参照しているコンポーネントテストを行いました。

できごと

Swaggerを元に構築できるPrismでモックサーバーを立ち上げ、recoil selector(中でフェッチ)を呼び出したコンポーネントがあります。
今回はそのコンポーネントのテストをする際、CIではPrismを立てずにテストを行いたいと思いました。

該当のコートが以下になります。

libs/axiosClient:aspida/axios使用
import aspida from '@aspida/axios';
import axios from 'axios';

import api from '../apis/$api'; //swaggerを参照したaspida型定義

export const client = api(aspida(axios, { baseURL: 'http://localhost:8080' }));
store/shopsSelector: Recoilのselector内でフェッチした店舗一覧を取得
import { selector } from 'recoil';
import { client } from '../libs/axiosClient';

export const shopListState = selector({
  key: 'shopList',
  get: async () => {
    const res = await client.shops.get(); // aspida/axiosでshopsの呼び出し
    return res.body.data;
  },
});
pages/Shops.tsx: selectorを参照したコンポーネント
import { useRecoilValue } from 'recoil';
import { shopListState } from '../store/shopsSelector';

export const Shops: React.FC = () => {
    // shopListState内のフェッチした戻り値を参照
  const shops = useRecoilValue(shopListState);

  return (
    <div>
      <div>Shops</div>
      <ul className="flex justify-center">
        {shops.map(({ id, name }) => {
          return <li key={id}>{name}</li>;
        })}
      </ul>
    </div>
  );
};
/__test__/Shops.test.tsx: Shopsコンポーネントをスナップショットするテストコード

import { render, screen, waitFor } from '@testing-library/react';
import { Suspense } from 'react';
import { BrowserRouter } from 'react-router-dom';
import { RecoilRoot } from 'recoil';

import { Shops } from '../pages/Shops';

describe('<Shops />', () => {
  test('正常にレンダリングできる', async () => {
    const { container } = render(
      <RecoilRoot>
        <BrowserRouter>
          <Suspense fallback="...loading">
            <Shops />
          </Suspense>
        </BrowserRouter>
      </RecoilRoot>
    );

    await waitFor(() => expect(screen.getByText('ドラッグストア')).toBeTruthy());

    expect(container).toMatchSnapshot();
  });
});

prism立ち上げ

スクリーンショット 2022-09-07 0.04.45.png

これの状態でテストを通すことが可能ですが、モックサーバーを立てないと以下のようにNetwork Errorでテストが通りません。
スクリーンショット 2022-09-09 9.11.05.png

ここまででモックサーバを立てて実装を行なっている場合、コンポーネントのレンダリングテストをする時にローカル上ではモックサーバーを立てればテストを通すことが可能だが、CI上に載せる場合コンテナにモックサーバーを置く方法もありましたが管理コストをかけたくなく、テスト時にaspida-mockに切り替えてテストを行う手法にしました。

本題

APIをフェッチするaspida/axiosでテスト時にaspida-mockのモックデータでテストを通すようにしました。

aspida-mockでAPIモックを作成

apiレスポンスやbodyの内容をモックとして定義し、生成を行うことでテストを通すことができる型が完成します。
生成したmockファイルを呼び出し以下のように変更します。

libs/axiosClient:aspida/axios使用
- import aspida from '@aspida/axios';
+ import { default as aspida, default as aspidaClient } from '@aspida/axios';
import axios from 'axios';
+ import mock from '../apis/$mock';

import api from '../apis/$api'; //swaggerを参照したaspida型定義

- export const client = api(aspida(axios, { baseURL: 'http://localhost:8080' }));
+ export const client =
+ process.env['NODE_ENV'] === 'test'
+   ? mock(aspidaClient())
+   : api(aspida(axios, { baseURL: 'http://localhost:8080' }));

テスト時にだけaspida-mockに切り替えてテストを通すというトリッキー形で正常に通すことができました。
そしてこの状態でPrismを立てず、テストを走らせると、
スクリーンショット 2022-09-09 9.36.03.png
無事テストを通すことが出来ました。

補足

what is aspida?

REST APIの型定義を行うライブラリで型安全にリクエスト・レスポンスができます。

またopenapi2aspidaでSwaggerを元にaspidaで型定義ファイルを生成することが出来ます。

また今回したswaggerと実際にaspidaで生成された型定義ファイルは以下になります。

今回サンプルで作成したSwaggerファイル

openapi: 3.0.3
info:
  description: leran example swagger
  version: 'v0.0.1'
  title: dashboard API
servers: # APIのサーバー指定
  - url: https://dev.kotaro-shop.com/v1
    description: Development server
paths:
  /shops:
    get:
      tags:
        - 'shops'
      summary: fetch shops
      parameters:
        - $ref: '#/components/parameters/Authorization' #コンポーネント参照($ref)
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/offset'
      responses:
        '200':
          $ref: '#/components/responses/Shops'
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Not Found

    post:
      tags:
        - 'shops'
      summary: create shops
      parameters: []
      requestBody:
        description: 登録したい店舗一覧
        required: true
        content:
          multipart/form-data:
            schema:
              properties:
                file:
                  type: string
                  format: binary
                  description: 店舗情報
      responses:
        '201':
          description: created shops from csv file.
  /shops/{shopId}:
    parameters:
      - $ref: '#/components/parameters/shopId'
    get:
      summary: ショップ詳細
      description: ショップの詳細を取得する
      tags:
        - 'shops'
      responses:
        '200':
          $ref: '#/components/responses/ShopDetail'
        '401':
          description: Unauthorized
        '404':
          description: Not Found
      parameters:
        - $ref: '#/components/parameters/Authorization'
    put:
      tags:
        - 'shops'
      summary: Update a shop by ID
      parameters:
        - in: path
          name: shopId
          description: 'shop identifier'
          schema:
            type: string
          required: true
          example: '1'
      responses:
        '200':
          description: OK
    delete:
      tags:
        - 'shops'
      summary: Delete a shop by ID
      parameters:
        - in: path
          name: shopId
          description: 'shop identifier'
          schema:
            type: string
          required: true
          example: '1'
      responses:
        '200':
          description: OK

tags:
  - name: shops
components:
  schemas:
    Shop:
      title: Shop
      type: object
      description: 店舗
      properties:
        id:
          type: integer
        name:
          type: string
        telephoneNumber:
          type: string
        zipCode:
          type: string
        address:
          type: string
      x-examples:
        example-1:
          id: '1'
          name: 'ドラッグストア渋谷店'
          telephoneNumber: '03-1234-5678'
          zipCode: '1500042'
          address: '東京都渋谷区宇田川町40−1'

  parameters:
    limit:
      name: limit
      in: query
      required: false
      schema:
        type: integer
        default: 10
      description: 1ページあたりの最大表示件数
    offset:
      name: offset
      in: query
      required: false
      schema:
        type: integer
        default: 1
      description: 何件目から取得するか
    Authorization:
      name: Authorization
      in: header
      required: false
      schema:
        type: string
      description: 認証情報
    shopId:
      name: shopId
      in: path
      required: true
      schema:
        type: integer
      description: ショップID

  responses:
    Shops:
      description: ショップ一覧のレスポンス
      content:
        application/json:
          schema:
            type: object
            properties:
              data:
                type: array
                items:
                  $ref: '#/components/schemas/Shop'
          examples:
            example-1:
              value:
                data:
                  - id: 1
                    name: 'ドラッグストア'
                    telephoneNumber: '00-0000-0000'
                    zipCode: '0000000'
                    address: '東京都渋谷区'

                  - id: 2
                    name: 'サンドラッグ'
                    telephoneNumber: '11-1111-1111'
                    zipCode: '1111111'
                    address: '新宿3丁目'

                  - id: 3
                    name: 'マツモトキヨシ'
                    telephoneNumber: '22-2222-2222'
                    zipCode: '2222222'
                    address: '東京都墨田区'

    ShopDetail:
      description: ショップ詳細のレスポンス
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Shop'
          examples:
            example-1:
              value:
                id: 1
                name: 'ドラッグストア'
                telephoneNumber: '00-0000-0000'
                zipCode: '0000000'
                address: '東京都渋谷区'
実際に型が生成されたファイル(一部)
apis/shops/$api.ts

import type { AspidaClient, BasicHeaders } from 'aspida';
import { dataToURLString } from 'aspida';

import type { Methods as Methods1 } from './_shopId@number';

import type { Methods as Methods0 } from '.';

const api = <T>({ baseURL, fetch }: AspidaClient<T>) => {
  const prefix = (baseURL === undefined ? '' : baseURL).replace(/\/$/, '');
  const PATH0 = '/shops';
  const GET = 'GET';
  const POST = 'POST';
  const PUT = 'PUT';
  const DELETE = 'DELETE';

  return {
    _shopId: (val0: number) => {
      const prefix0 = `${PATH0}/${val0}`;

      return {
        /**
         * ショップの詳細を取得する
         * @returns ショップ詳細のレスポンス
         */
        get: (
          option?:
            | { headers?: Methods1['get']['reqHeaders'] | undefined; config?: T | undefined }
            | undefined
        ) =>
          fetch<Methods1['get']['resBody'], BasicHeaders, Methods1['get']['status']>(
            prefix,
            prefix0,
            GET,
            option
          ).json(),
        /**
         * ショップの詳細を取得する
         * @returns ショップ詳細のレスポンス
         */
        $get: (
          option?:
            | { headers?: Methods1['get']['reqHeaders'] | undefined; config?: T | undefined }
            | undefined
        ) =>
          fetch<Methods1['get']['resBody'], BasicHeaders, Methods1['get']['status']>(
            prefix,
            prefix0,
            GET,
            option
          )
            .json()
            .then((r) => r.body),
        put: (option?: { config?: T | undefined } | undefined) =>
          fetch<void, BasicHeaders, Methods1['put']['status']>(prefix, prefix0, PUT, option).send(),
        $put: (option?: { config?: T | undefined } | undefined) =>
          fetch<void, BasicHeaders, Methods1['put']['status']>(prefix, prefix0, PUT, option)
            .send()
            .then((r) => r.body),
        delete: (option?: { config?: T | undefined } | undefined) =>
          fetch<void, BasicHeaders, Methods1['delete']['status']>(
            prefix,
            prefix0,
            DELETE,
            option
          ).send(),
        $delete: (option?: { config?: T | undefined } | undefined) =>
          fetch<void, BasicHeaders, Methods1['delete']['status']>(prefix, prefix0, DELETE, option)
            .send()
            .then((r) => r.body),
        $path: () => `${prefix}${prefix0}`,
      };
    },
    /**
     * @returns ショップ一覧のレスポンス
     */
    get: (
      option?:
        | {
            query?: Methods0['get']['query'] | undefined;
            headers?: Methods0['get']['reqHeaders'] | undefined;
            config?: T | undefined;
          }
        | undefined
    ) =>
      fetch<Methods0['get']['resBody'], BasicHeaders, Methods0['get']['status']>(
        prefix,
        PATH0,
        GET,
        option
      ).json(),
    /**
     * @returns ショップ一覧のレスポンス
     */
    $get: (
      option?:
        | {
            query?: Methods0['get']['query'] | undefined;
            headers?: Methods0['get']['reqHeaders'] | undefined;
            config?: T | undefined;
          }
        | undefined
    ) =>
      fetch<Methods0['get']['resBody'], BasicHeaders, Methods0['get']['status']>(
        prefix,
        PATH0,
        GET,
        option
      )
        .json()
        .then((r) => r.body),
    /**
     * @param option.body - 登録したい店舗一覧
     */
    post: (option: { body: Methods0['post']['reqBody']; config?: T | undefined }) =>
      fetch<void, BasicHeaders, Methods0['post']['status']>(
        prefix,
        PATH0,
        POST,
        option,
        'FormData'
      ).send(),
    /**
     * @param option.body - 登録したい店舗一覧
     */
    $post: (option: { body: Methods0['post']['reqBody']; config?: T | undefined }) =>
      fetch<void, BasicHeaders, Methods0['post']['status']>(prefix, PATH0, POST, option, 'FormData')
        .send()
        .then((r) => r.body),
    $path: (option?: { method?: 'get' | undefined; query: Methods0['get']['query'] } | undefined) =>
      `${prefix}${PATH0}${option && option.query ? `?${dataToURLString(option.query)}` : ''}`,
  };
};

export type ApiInstance = ReturnType<typeof api>;
export default api;

what is aspida-mock?

apida専用でHTTPメソッドに対応しているサーバー不要なAPIモックが生成できる。

HTTPメソッドに対してステータスやレスポンスボディをモックとしてオリジナルで作り、aspida-mockコマンドでrouterを生成するようになってますね。

実際に今回モックとして定義したファイルがこちらになります。

モックデータの定義
src/apis/shops/index.ts
+ import { mockMethods } from 'aspida-mock';
import type { ReadStream } from 'fs';
import type * as Types from '../@types';

export type Methods = {
  get: {
    reqHeaders?: Types.Authorization | undefined;
    query?: (Types.Limit & Types.Offset) | undefined;
    status: 200;

    /** ショップ一覧のレスポンス */
    resBody: {
      data: Types.Shop[];
    };
  };

  post: {
    status: 201;
    reqFormat: FormData;

    /** 登録したい店舗一覧 */
    reqBody: {
      /** 店舗情報 */
      file: File | ReadStream;
    };
  };
};

+ export default mockMethods<Methods>({
+ get: () => ({
+   status: 200,
+   resBody: {
+     data: [
+       {
+         id: 1,
+         name: 'ドラッグストア',
+         telephoneNumber: '00-0000-0000',
+         zipCode: '0000000',
+         address: '東京都渋谷区',
+       },
+        {
+          id: 2,
+         name: 'サンドラッグ',
+         telephoneNumber: '11-1111-1111',
+         zipCode: '1111111',
+         address: '東京都新宿3丁目',
+       },
+       {
+         id: 3,
+         name: 'マツモトキヨシ',
+          telephoneNumber: '22-2222-2222',
+          zipCode: '1300022',
+          address: '東京都墨田区',
+        },
+      ],
+    },
+  }),
+ });

今回apida-mockコマンドで自動生成したファイル

/src/apis/$mock.ts
/* eslint-disable */
// prettier-ignore
import { AspidaClient } from 'aspida'
// prettier-ignore
import { MockClient, MockConfig, mockClient } from 'aspida-mock'
// prettier-ignore
import api from './$api'
// prettier-ignore
import mock0 from './shops/index'

// prettier-ignore
export const mockRoutes = () => [
  { path: '/shops', methods: mock0 }
]

// prettier-ignore
export default <U>(client: AspidaClient<U> | MockClient<U>, config?: MockConfig) => {
  const mock = 'attachRoutes' in client ? client : mockClient(client)
  mock.attachRoutes(mockRoutes(), config)

  return api(mock)
}

まとめ

Prism, aspidaでAsynchronous Data Queriesを呼び出しているコンポーネントテストは、aspida-mockに切り替えてモックデータ&Jestでテストを行うことが可能になる。(CI上でも然り)

最終的に落ち着いたaspida/axiosクライアント
import { default as aspida, default as aspidaClient } from '@aspida/axios';
import axios from 'axios';

import api from '../apis/$api';
import mock from '../apis/$mock';

export const client =
  process.env['NODE_ENV'] === 'test'
    ? mock(aspidaClient())
    : api(aspida(axios, { baseURL: 'http://localhost:8080' }));

余談

今記事を執筆していてやっぱり思うことは新しいPJでPrism, aspida, Recoilを初めて使うというのもあってReact, TSで開発するにあたって良いライブラリを使っているはずが本末転倒がめちゃくちゃありました。

もちろんaspida@openapiswaggerベースの型定義ファイルの生成やHTTPメソッドに対しても型安全で開発できる利点はaspida使う分にはフロント開発がより一層しやすいなと思いました。

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?