この記事は HRBrain Advent Calendar 2021 13日目の記事です。
はじめに
こんにちは。フロントエンドエンジニアの村崎です。
社会人になって8ヶ月が経ちました。あっという間ですね。
みなさんはどのような方法でテストに使うmockを定義していますか?
弊社では、OpenAPIを用いてスキーマ駆動開発を行っているプロダクト及びチームが多く存在し、フロント・サーバーそれぞれgeneratorを用いて型を自動生成しています。
そこで、スキーマからテストで扱うmockデータも生成できたら便利ではないか?と考えました。
今回はOpenAPIのスキーマからmockを生成して、フロントエンドのあらゆるテストで使い倒す方法を紹介します。
前提
最終形のコードはこちらになります。
現在開発を担当しているプロダクトでViteを採用しているため、この記事でもViteを用います。 全体の環境は以下になります。
- React
- TypeScript
- Vite
- openapi-generator-cli
- Storybook
- Jest(SWC)
- MSW
- React Testing Library
使用するスキーマ
今回使用するスキーマはこちらです。 パラメータで指定したidのTodoを返すAPIが定義されてるだけのスキーマになります。
exampleキーが今回生成するmock対象になります。
openapi: 3.0.3 info: title: "sample" version: "1.0.0" paths: /todo/{id}: parameters: - schema: type: string name: id in: path required: true get: operationId: getTodo summary: Get todo responses: '200': description: OK content: application/json: schema: $ref: "#/components/schemas/Todo" components: schemas: Todo: type: object properties: id: type: string title: type: string body: type: string completed: type: boolean required: - id - title - body - completed example: id: 'testID' title: 'Todo title' body: 'Todo body' completed: false
yamlからmockを生成する
スキーマがjsonであればそのままimportできるのですが弊社ではyamlが採用されているため、そのままでは読み込めません。 今回は、Viteを使用しているためこちらのrollupのpluginを使用します。
// vite.config.ts import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import yaml from '@rollup/plugin-yaml' export default defineConfig({ plugins: [react(), yaml()], })
読みこんだyamlを元にmockを返す関数を用意します。 実際の開発では、OpenAPIから生成した型をそのまま使わず、アプリケーション内で定義した型に変換する場合もあるので、アプリケーション内で定義したdomainの型に変換してくれる関数も用意します。中身はfetch時に呼ぶdto関数をそのまま呼んでいるだけです。
// src/__mocks__/todo.ts import yaml from '../../../schema/schema.yaml' import * as Oapi from '../oapi' import * as Domain from '../domain' import { toTodo } from '../services/todo' export const mockTodo = (todo?: Partial<Oapi.Todo>): Oapi.Todo => ({ ...yaml.components.schemas.Todo.example, ...todo, }) export const mockTodoToDomain = (todo?: Partial<Domain.Todo>): Domain.Todo => ({ ...toTodo(mockTodo()), ...todo, })
また、このままだと.yamlが読み込めないとTypeScriptに怒られるため定義します。
// src/types.d.ts declare module '*.yaml' { const data: any export default data }
domainとdtoの中身はこちらです。申し訳程度にフロント用のプロパティを追加しています。使い道はなさそうです。
// domain/todo export type Todo = { id: string title: string body: string completed: boolean isEmptyBody: boolean } // services/todo/dto // openapi-generatorで生成した型からアプリケーションの型に変換する import * as Oapi from '../../oapi' import * as Domain from '../../domain' export const toTodo = (todo: Oapi.Todo): Domain.Todo => ({ id: todo.id, title: todo.title, body: todo.body, completed: todo.completed, isEmptyBody: todo.body === '', })
Storybook
実際にmockを使っていきましょう。まずはStorybookです。
アプリケーションと合わせるためにbuilderにViteを指定します。 先ほど追加したrollupのpluginをここでも追加します。
// .storybook/main.js const yaml = require('@rollup/plugin-yaml') module.exports = { stories: ['../src/**/*.stories.tsx'], addons: ['@storybook/addon-links', '@storybook/addon-essentials'], framework: '@storybook/react', core: { builder: 'storybook-builder-vite', }, viteFinal: (config) => { config.plugins = [...config.plugins, yaml()] return config }, }
テストコードは用意したdomain用の関数をimportして呼ぶだけになります。
また、CSF3の記法で書いています。
import type { ComponentStoryObj } from '@storybook/react' import { Todo } from './Todo' import { mockTodoToDomain } from '../__mocks__/todo' type Story = ComponentStoryObj<typeof Todo> export default { component: Todo, title: 'Components/Todo' } export const Default: Story = { args: { todo: mockTodoToDomain() }, }
対象のコンポーネントはこちらです。propsでもらったtodoを表示させるだけのシンプルなコンポーネントです。
import * as React from 'react' import * as Domain from '../domain' type Props = { todo: Domain.Todo } export const Todo = (props: Props) => ( <div> <h1>{props.todo.title}</h1> <p>{props.todo.isEmptyBody ? 'empty' : props.todo.body}</p> <label>completed</label> <input type="checkbox" checked={props.todo.completed} disabled /> </div> )
Jest
次にJestです。先ほどのdto関数のユニットテストを書いてみたいと思います。
まず、このままだとJest実行時にyamlが読めずエラーになってしまうため、parserを用意する必要があります。 今回はこちらのライブラリを使用します。
また、速い方が嬉しいためSWCを使用していますが、ts-jestやbabel-jestでも大丈夫です。 テストは、用意したmock関数を呼び出すだけです。
// jest.config.js module.exports = { testEnvironment: 'jsdom', transform: { '\\.yaml$': 'jest-transform-yaml', '^.+\\.(ts|tsx)?$': '@swc/jest', }, }
// services/todo/dto.spec.ts import { mockTodo } from '../../__mocks__/todo' import { toTodo } from './dto' describe('dto', () => { it('toTodo', () => { expect(toTodo(mockTodo())).toStrictEqual({ id: 'testID', title: 'Todo title', body: 'Todo body', completed: false, isEmptyBody: false, }) }) })
MSW
MSWも用意したmock関数を呼ぶだけです。 サーバーから直接返ってくるデータのため、OpenAPIの型のTodoを返す関数を呼んでいます。
// src/__mocks__/msw/handlers.ts import { rest, DefaultRequestBody } from 'msw' import { mockTodo } from '../../todo' export const handlers = [ rest.get<DefaultRequestBody, { id: string }>('/todo/:id', (req, res, ctx) => { const { id } = req.params if (!id) return res(ctx.status(400)) return res(ctx.status(200), ctx.json(mockTodo({ id }))) }), ]
React Testing Library
直接mockを使用している訳ではないのですが、MSWを活用しているため少しですが書いてみます。 useTodo内ではreact-queryを使用していますが、SWRでも同じテストが書けます。
// src/App.tsx import * as React from 'react' import { useTodo } from './services/todo' import { Todo } from './components/Todo' export const App = () => { const { data, isLoading, isError } = useTodo() if (isLoading) { return <p>loading...</p> } if (isError || !data) { return <p>error!</p> } return ( <div> <Todo todo={data} /> </div> ) }
// src/App.spec.ts import React from 'react' import { rest } from 'msw' import { setupServer } from 'msw/node' import { render, waitFor, screen } from '@testing-library/react' import '@testing-library/jest-dom' import { QueryClient, QueryClientProvider } from 'react-query' import { handlers } from './__mocks__/msw/handlers' import { App } from './App' const server = setupServer(...handlers) beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }) const wrapper = ({ children }) => ( <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> ) describe('App', () => { it('should be displayed loading', () => { render(<App />, { wrapper }) expect(screen.getByText('loading...')).toBeInTheDocument() }) it('should be displayed error!', async () => { server.use( rest.get('/todo/:id', (req, res, ctx) => { return res(ctx.status(400)) }) ) render(<App />, { wrapper }) await waitFor(() => screen.getByText('error!')) expect(screen.queryByText('loading...')).not.toBeInTheDocument() }) })
まとめ
OpenAPIスキーマから生成したmockを使用することで、より本番に近く、一貫性があるデータでテストを書くことができました。 スキーマが保守されていれば常に最新の値を反映できるのが嬉しいところです。 ここはこうした方がいい!やもっといいやり方あるよ!という方がいましたら、是非教えてください。 また、弊社に興味を持った方がいれば、ぜひ下記ページからご応募ください!