🛠

OpenAPI定義をmswに活用してお手軽モック

2021/09/10に公開

Leaner 開発チームの黒曜(@kokuyouwind)です。

RubyKaigi Takeout 2021真っ只中ですね。自分も視聴して実況ツイートを垂れ流しています。
でも今回はフロントエンドの話です。

TL;DR

OpenAPI 定義を JSON で記述しておくと、 import schema from 'schema.json' で読み込んで Component などの定義を参照できます。
これを使って msw の Handler を定義すると、簡単に API をモックしてテストできるので便利です。
異常系のハンドラーも同じところで定義しておくと、テスト時のレスポンス差し替えもシンプルになって読みやすくなります。

OpenAPI 定義を msw に使いたい

以前書いたとおり、現在自分が担当しているプロダクトでは OpenAPI を使って API スキーマを定義しています。

https://zenn.dev/leaner_tech/articles/20210709-leaner-techstack-202107#leaner-purchasing-system(仮)-の技術スタック

このスキーマ定義からopenapi2aspidaを用いて API クライアントを生成し、@aspida/swr経由でデータを取得することで、シンプルかつ型の効いたフロントエンド実装ができています。

ところで、テスト時は API を実際に呼び出すわけにはいかないので、 msw を使ってモックしています。 OpenAPI の定義を利用して、この Handler を生成できたら便利ではないでしょうか?
その疑問を解明すべく、我々は GitHub Issues の奥地へ向かいました。

https://github.com/mswjs/msw/issues/394

「swagger doc を使って API レスポンスを生成する簡単な方法があると便利じゃない?」
「凝ったツール使わなくても、 JSON 普通に読み込んで手作業でマッピング書けば十分でしょ」
(意訳)

はい。

OpenAPI 定義を読み込んで msw Hander を作ろう

おとなしく OpenAPI 定義を読み込んで msw Handler を作るコードを手で書いていきましょう。

例として、以下の OpenAPI スキーマから msw Hander を作っていきます。ログインユーザを返す GET /user だけを定義したシンプルなスキーマです。

// shared/schema.json

{
  "openapi": "3.0.2",
  "components": {
    "schemas": {
      "User": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" },
          "email": { "type": "string", "format": "email" },
          "required": [ "id", "email", ]
        },
        "examples": {
          "default": {
            "value": { "id": 1, "email": "user@example.com" }
          }
        },
        "additionalProperties": false,
      }
    }
  },
  "paths": {
    "/user": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                },
                "examples": {
                  "default": {
                    "value": {
                      "$ref": "#/components/schemas/User/x-examples/default/value"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Login Required",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {}
                }
              }
            }
          }
        }
      }
    }
  }

まずは、 Componentsexamples を使ってリソースのサンプルデータを作ります。

// lib/msw/components.ts

import schema from 'shared/schema.json'

const components = {
  User: schema.components.schemas.User['examples'].default.value,
}
export default components

次に、 path 定義に沿って handlers を作りましょう。

ここは OpenAPI Schema をパースして持ってこれるとカッコいいのですが、そこまで大変でもないので components を素直に参照します。

// lib/msw/handlers.ts

import { DefaultRequestBody, MockedRequest, rest, RestHandler } from 'msw'
import components from './components'

// Handler Helpers
const request = (method: typeof rest.get) => {
  return (
    path: string,
    response: Record<string, unknown> | Array<unknown> | null = {},
    status = 200,
    baseURL = 'http://localhost:3000'
  ): RestHandler<MockedRequest<DefaultRequestBody>> => {
    return method(`${baseURL}${path}`, (_req, res, ctx) => {
      return res(ctx.status(status), ctx.json(response))
    })
  }
}
const get = request(rest.get)

// Handlers
const handlers = {
  get_current_user: {
    ok: get('/user', components.User),
    not_found: get('/user', {}, 404),
  }
}

const default_handlers = Object.entries(handlers).map(([_k, status_handlers]) => {
  return status_handlers.ok
})

export { handlers, default_handlers }

default_handlers はいわゆる Happy Path のレスポンスハンドラを集めたものです。
これを用いて setupServer を呼び出すことで、正常系のテストを特別な設定なしに行うことができます。

// lib/msw/server.ts
import { setupServer } from 'msw/node'
import { default_handlers } from './handlers'

const server = setupServer(...default_handlers)
export default server


// jest.setup.ts
import server from 'lib/msw/server'
import { cleanup } from '@testing-library/react'

beforeAll(() => server.listen())
afterEach(async () => {
  cleanup()
  server.resetHandlers()
})
afterAll(() => server.close())

これらを用いて、「ルートページにアクセスしたとき、ログイン済みならホーム画面に、未ログインならログイン画面が表示される」というテストを書くと以下のようになります。

import '@testing-library/jest-dom/extend-expect'
import server from 'lib/msw/server'
import { handlers } from 'lib/msw/handlers'
import { screen, waitFor } from '@testing-library/react'
import getPage from 'lib/test/get_page'

describe('Root page', () => {
  describe('ログイン時', () => {
    it('ホームページが表示される', async () => {
      const { render } = await getPage({ route: '/' })
      render()

      await waitFor(() => {
        screen.getByText('ホームページ')
      })
    })
  })

  describe('未ログイン時', () => {
    it('ログイン画面が表示される', async () => 
      server.use(handlers.get_current_user.not_found)

      const { render } = await getPage({ route: '/' })
      render()

      await waitFor(() => {
        screen.getByText('ログイン')
      })
    })
  })
})

正常系であるログイン時は、特に msw のメソッドを呼び出すことなくテストを行うことができています。

また異常系である未ログインを模倣する場合も、 server.use(handlers.get_current_user.not_found) とするだけでハンドラを差し替えることができ、詳細を隠蔽して可読性の高いテストになっています。

Storybook で OpenAPI Schema の Component Example を使う

OpenAPI Schema から取得した Component Example は、 Storybook でも便利に使うことができます。

例えばユーザのプロフィールを表示する UserProfile コンポーネントが user: User を受け取る場合、ストーリー内に個別のサンプルユーザデータを記述する代わりに components.User を渡すだけで済ませることができます。

// __stories__/users/_UserProfile.stories.tsx

import React from 'react'
import { Meta } from '@storybook/react'
import UserProfile from 'components/UserProfile'
import components from 'lib/msw/components'

export default {
  component: UserProfile,
  title: 'Users/UserProfile'
} as Meta

export const Loaded: React.VFC = () => (
  <UserProfile user={components.User} />
)

このようにするとストーリーごとに個別でデータを用意しなくて良くなるのに加えて、ユーザの属性が増えても OpenAPI Schema が正しく保守されていればストーリーを修正せずに最新の属性値を反映できます。

まとめ

読んでいただいて分かる通り、実際に使っているのは OpenAPI Schema 定義のうち Component の定義のみでした。

これさえ読み込んで扱いやすい名前をつけておけば、 msw のハンドラ定義や Storybook のストーリーなど各所にダミーデータを渡しやすくなるので便利です。

ぜひ試してみてください。

宣伝

Leaner Technologies では OpenAPI スキーマ定義を活用したいエンジニアを募集しています!

https://careers.leaner.co.jp/

リーナーテックブログ

Discussion