はじめに
初めてRecoilを使用するにあたってAsynchronous Data Queriesで非同期通信(aspida, axios)で行なっているSelectorがあり、それを呼び出しているコンポーネントのレンダリングテストをする時につまづいた出来事です。
今回はaspida-mockを使用してAPIを参照しているコンポーネントテストを行いました。
できごと
Swaggerを元に構築できるPrismでモックサーバーを立ち上げ、recoil selector(中でフェッチ)を呼び出したコンポーネントがあります。
今回はそのコンポーネントのテストをする際、CIではPrismを立てずにテストを行いたいと思いました。
該当のコートが以下になります。
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' }));
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;
},
});
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>
);
};
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立ち上げ
これの状態でテストを通すことが可能ですが、モックサーバーを立てないと以下のようにNetwork Errorでテストが通りません。
ここまででモックサーバを立てて実装を行なっている場合、コンポーネントのレンダリングテストをする時にローカル上ではモックサーバーを立てればテストを通すことが可能だが、CI上に載せる場合コンテナにモックサーバーを置く方法もありましたが管理コストをかけたくなく、テスト時にaspida-mockに切り替えてテストを行う手法にしました。
本題
APIをフェッチするaspida/axiosでテスト時にaspida-mockのモックデータでテストを通すようにしました。
aspida-mockでAPIモックを作成
apiレスポンスやbodyの内容をモックとして定義し、生成を行うことでテストを通すことができる型が完成します。
生成したmockファイルを呼び出し以下のように変更します。
- 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を立てず、テストを走らせると、
無事テストを通すことが出来ました。
補足
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: '東京都渋谷区'
実際に型が生成されたファイル(一部)
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を生成するようになってますね。
実際に今回モックとして定義したファイルがこちらになります。
モックデータの定義
+ 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コマンドで自動生成したファイル
/* 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上でも然り)
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@openapi
でswagger
ベースの型定義ファイルの生成やHTTPメソッドに対しても型安全で開発できる利点はaspida
使う分にはフロント開発がより一層しやすいなと思いました。