OpenAPIスキーマから生成したmockをテストで使い倒す

この記事は HRBrain Advent Calendar 2021 13日目の記事です。

qiita.com

はじめに

こんにちは。フロントエンドエンジニアの村崎です。

社会人になって8ヶ月が経ちました。あっという間ですね。

みなさんはどのような方法でテストに使うmockを定義していますか?

弊社では、OpenAPIを用いてスキーマ駆動開発を行っているプロダクト及びチームが多く存在し、フロント・サーバーそれぞれgeneratorを用いて型を自動生成しています。

そこで、スキーマからテストで扱うmockデータも生成できたら便利ではないか?と考えました。

今回はOpenAPIのスキーマからmockを生成して、フロントエンドのあらゆるテストで使い倒す方法を紹介します。

前提

最終形のコードはこちらになります。

github.com

現在開発を担当しているプロダクトでViteを採用しているため、この記事でもViteを用います。 全体の環境は以下になります。

使用するスキーマ

今回使用するスキーマはこちらです。 パラメータで指定した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>
)

storybook
Todoコンポーネント

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を使用することで、より本番に近く、一貫性があるデータでテストを書くことができました。 スキーマが保守されていれば常に最新の値を反映できるのが嬉しいところです。 ここはこうした方がいい!やもっといいやり方あるよ!という方がいましたら、是非教えてください。 また、弊社に興味を持った方がいれば、ぜひ下記ページからご応募ください!

www.hrbrain.co.jp