🧪

Jest × testing-libraryでmswをセットアップ

2024/01/31に公開

こんにちは。MI-6株式会社でmiHubの開発をしています、中澤です。

最近Jest × testing-libraryの環境でmsw(Mock Service Worker)を使えるようにする設定をしたのですが、いくつかハマりポイントがありました。

本記事ではmswはなんぞというのをサクッと紹介しつつ、実際使えるようにするところまでまとめます。

なぜmswを導入するのか

  • 弊社で開発しているmiHubはOpenAPIで定義したスキーマに沿った開発をしている
  • フロントのTypeScript(React)では、OASから自動生成した型定義を利用して処理を記述している
  • バックエンドもスキーマ定義に沿ったレスポンスになるか単体テストでOAS定義を利用している

この背景から、フロントエンドのテスト責務を明確にするためにAPI呼び出し部分はmswが使えると良さそうでした。

期待するmswの動き

mswはAPIリクエストをインターセプト(横入り)して、定義したmockを返してくれます。

今回は次のような動きを期待します。

  1. ページをレンダリング(testing library)
  2. ページからのgetリクエストをmswがmockしてレスポンスが返る
  3. mockデータに応じてレンダリングされた要素を取得
  4. 要素を操作(例えばチェックボックスをクリック)
  5. postアクションのボタンをクリック
  6. postのリクエストボディを検証(msw内で取得)

導入手順

jest, testing libraryは導入済みとします。
まずはmswをインストールしましょう。本番では使わないので、devオプションを付けます。

yarn add msw --dev

mswのhandler定義

mswをざっくりおさらいすると

  • リクエストURLをマッチさせて
  • 定義したresolverの処理を実行する

この動きをするものです。

次の関数がこれを表現しています。

handlers.ts
import { HttpRequestHandler } from 'msw'
const request = (method: HttpRequestHandler) => {
  return (
    path: string,
    response: Record<string, unknown> | unknown[] | null = {},
    status = 200,
    baseURL = BASE_URL,
  ) => {
    return method(`${baseURL}${path}`, resolver(method, response, status))
  }
}

resolverは公式ドキュメントのこちらを参考に実装します。

長いので全部は載せませんが以下のようなイメージです。
それぞれのHTTPメソッドでさせたい処理を記述します。とりあえず特別なことはせず、Jsonを返すようにします。

handlers.ts
import { DefaultBodyType, http, HttpRequestHandler, HttpResponse, PathParams } from 'msw'
import { HttpRequestResolverExtras } from 'msw/lib/core/handlers/HttpHandler'
import { ResponseResolverInfo } from 'msw/lib/core/handlers/RequestHandler'

const resolver = (method: HttpRequestHandler, response: Record<string, unknown> | unknown[] | null = {}, status = 200) => {
  switch (method) {
    case http.get:
    case http.delete:
      return ({ params }: ResponseResolverInfo<HttpRequestResolverExtras<PathParams>, DefaultBodyType>) => {
        return HttpResponse.json(response, { status, headers: { 'Content-Type': 'application/json' } })
      }
    case ...
  }
}

このように定義すると、必要なメソッドと具体的なハンドラを次のように定義できます。

handlers.ts
const get = request(http.get)

// Handlers
export const handlers = {
  get_posts: {
    ok: get('/posts', components.PostsIndexResponse, 200),
    unauthorized: get('/posts', null, 401),
  },
}

このような定義方法はこちらを参考にさせていただきました。
https://zenn.dev/leaner_dev/articles/20210908-openapi-msw-handlers

モックを定義する

先ほどの具体的なハンドラ内にあるcomponents.PostsIndexResponseはmockしたレスポンスボディの値です。

こちらの定義方法はふたつの考え方で悩みました。

  • OpenAPIのexamplesを読み込む(OpenAPI公式参照
  • OpenAPI定義からTypeを自動生成しているので、それを使ってts側で定義する

前者はSwaggerUIで確認できる利点がありますが、examplesでは$refが使えないのでメンテが大変になります。

後者は型定義を使うだけなので、オブジェクトをうまく使い回してレスポンスを定義できますが、SwaggerUIでは使えません。

我々はまだ小さなチームでSwaggerUIはあまり使わずなので、後者としました。

examples/post.ts
export const post: Post = {
  id: '1',
  title: 'Sample'
}
examples/response/PostsIndexResponse.ts
export const postsIndexResponse = {
  posts: [post, post]
}
components.ts
const requests = {...}
const responses = {
  PostsIndexResponse: postsIndexResponse,
  ...
}

export const componens = {
  ...requests,
  ...responses
}

Jestで使えるようにする

jestで使うにはあと一歩、工夫が必要です。

jestはnode上で機能するので、nodeで発生するリクエストをインターセプトするmswのmock serverを立ち上げる記述を書いていきましょう。(msw公式Doc

jest.config.jssetupFilesAfterEnvで指定している共通処理を記載するファイルに以下のような記述を追加します。

import '@testing-library/jest-dom'
import { server } from 'pathto/msw/server'

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

serverは先ほど定義したhandlerから定義します。私は以下のように正常系だけを集めたハンドラを定義し、それを利用しています。

handlers.ts
export const defaultHandlers = Object.values(handlers).map(handler => handler.ok)
server.ts
import { setupServer } from 'msw/node'
import { defaultHandlers } from './handlers'

export const server = setupServer(...defaultHandlers)

以上でセットアップ完了です!さぁ、テストで動かしてみましょう。

動かない

2024年1月現在、mswを普通に入れるとバージョン2.x系が入ります。

mswは2023年秋ごろに1.x→2.xのバージョンアップがあったようで、単純にインストールするだけではうまく使えない場合があるようです。

Cannot find module ‘msw/node’

最初にこれが出ました。
公式が1.x→2.x migrate guidelineで言及してくれています。(これを探すのに苦労した...)

ガイドラインに従って、jest.config.jsに以下を追記します。

jest.config.js
module.exports = {
  testEnvironmentOptions: {
    customExportConditions: [''],
  },
}

Request/Response/TextEncoder is not defined

次に出たエラーがこちらです。先ほどのガイドにこれも載ってますね。
しかし、そのままでは動きませんでしたのでこちらのDiscussionを参考に、次のように変更しました。

// jest.polyfills.js
/**
 * @note The block below contains polyfills for Node.js globals
 * required for Jest to function when running JSDOM tests.
 * These HAVE to be require's and HAVE to be in this exact
 * order, since "undici" depends on the "TextEncoder" global API.
 *
 * Consider migrating to a more modern test runner if
 * you don't want to deal with this.
 */

- const { TextDecoder, TextEncoder } = require('node:util')
+ const { TextDecoder, TextEncoder, ReadableStream } = require('node:util')

Object.defineProperties(globalThis, {
  TextDecoder: { value: TextDecoder },
  TextEncoder: { value: TextEncoder },
+ ReadableStream: { value: ReadableStream },
})

const { Blob, File } = require('node:buffer')
const { fetch, Headers, FormData, Request, Response } = require('undici')

Object.defineProperties(globalThis, {
  fetch: { value: fetch, writable: true },
  Blob: { value: Blob },
  File: { value: File },
  Headers: { value: Headers },
  FormData: { value: FormData },
  Request: { value: Request },
  Response: { value: Response },
})

jest.config.jsにこのポリフィルを読み込むように追記します。

jest.config.js
module.exports = {
  testEnvironmentOptions: {
    customExportConditions: [''],
  },
}

先ほどのファイルでundiciというパッケージが必要になるので、これも入れましょう。
弊社ではAxiosを利用しているのですが、その影響で普通にundiciを入れるとエラーが発生しました。

現在undiciはv6.x系らしいのですが、どうもv5.x系でないとうまく動かないようです。(参考にしたmswのDiscussion

yarn add undici@^5.0.0 --dev

まとめ

Jest × testing-libraryの環境でmswを使えるようにするまでをまとめました。
mswはv1.x系の情報が多く、構築までかなり時間がかかってしまいました。

実は上記の環境でテストを実行しようとすると、場合によってはさらにエラーに遭遇すると思います。
Jest × testing-library、つまりnode上で複雑なUIをテストしようとすると、window apiがなかったりでエラーが出てしまうのです。

特にWebWorkerやIntersection Observerを利用しているプロダクトの場合、かなりモックが必要になります。(実装をもっと綺麗に責務分割できれば...みたいなことが頭をよぎる)

そんなモック状態が目的に合わなさそうだ...ということで、実は弊社ではPlaywrightを用いたブラウザテストを採用する流れになりました。

その場合でもmswは有効に機能します。この件についてはまた今度。

MI-6株式会社

Discussion