mswで快適モック生活

こんにちは。梅津です。
今回は msw(Mock Service Worker) に関する話です。
Kent C. Doddsが 自身のブログEpic React などで推していたのをきっかけに知りました。
実際にサービスに導入してみたところ、使い勝手がなかなか良かったので簡単に紹介したいと思います。

mswとは

mswのトップページでは次のように書かれています。

Mock by intercepting requests on the network level. Seamlessly reuse the same mock definition for testing, development, and debugging.
ネットワークレベルでリクエストをインターセプトしてモックを作成。同じモックの定義をテスト、開発、デバッグにシームレスに再利用することができます。

ものすごくざっくりとした説明としては、「いい感じにモックサーバーを作ってくれるもの」ということですね。
「ネットワークレベルで」というところがミソで、mswを導入することで fetch や APIを叩く関数をモックしていたコードがなくなります。
これによって実際のプロダクトコードとテストやStorybookで使うコードに差異がなくなるため、より信頼性の高いコードを書くことができます。

導入が簡単

設定に必要なコマンドはあらかじめ用意されていますし、ドキュメントもしっかり整備されているため、変にハマるようなことはありませんでした。
導入してからしばらく使い続けていますが、運用に関しても特にむずかしい点はありません。

GraphQLのモックができる

RESTだけでなくGraphQLのモックサーバーを作れます。
現在関わっているサービスではGraphQLを利用しているため、mswがGraphQLに対応してくれていて本当に良かったなと思います。

ローカルの開発やテストだけでなくStorybookでも同じモックを使い回せる

これが個人的には一番嬉しいポイントです!
コンポーネントを作るとき、無邪気に fetch などを使って外界とやりとりするようなコードを書いてしまうと、とたんにStorybookなどでの扱いが難しくなりますよね。
これまでは Container Component と Presentational Component を上手く分けて、fetch もしくはそのラッパーの関数などをその都度モックしていたことでしょう。
mswを利用することで、この辺の煩わしさから開放されます。
ブラウザで動くように作られたコンポーネントは、特に手を加えずそのままの形でStorybookで扱えます。
もちろん、無邪気に fetch を使えるからと言って、なんでもかんでもコンポーネントに詰め込んでいいというわけではないですが、Storybookやテストのために考えなければいけなかったことが減るというのは嬉しいものです。

導入事例

ここからはmswをどのような形で使っているのか見ていきます。

フォルダ・ファイル構成

フォルダ構成は次のようになっています。

├── public
│    └── mockServiceWorker.js
├── src
│    ├── mocks
│    │    ├── fixtures
│    │    │    ├── image.jpg
│    │    ├── handlers
│    │    │    ├── hoge
│    │    │    │    ├── data
│    │    │    │    └── handlers.ts
│    │    │    ├── fuga
│    │    │    │    ├── data
│    │    │    │    └── handlers.ts
│    │    │    └── index.ts
│    │    ├── browser.ts
│    │    └── server.ts

public/mockServiceWorker.js

mswが吐き出すコードです。
Setup に沿って準備を整えていけば自然とこのあたりのフォルダに置くことになります。
吐き出されたmockServiceWorker.jsは バージョン管理に含める よう勧められています。

src/mocks/browser.ts

ローカル環境やStorybookなど、ブラウザで利用するワーカーを定義する場所です。
基本的なファイル名などはmswが用意している Integrate > Browser のドキュメント に則っています。

// src/mocks/brower.ts
import { setupWorker, SetupWorkerApi } from "msw"
import { handlers } from "./handlers"

export * from "msw"

const worker: SetupWorkerApi = setupWorker(...handlers)

export async function startMockWorker() {
  await worker.start()
}

worker を直接exportせず関数にラップしてexportしているのは、worker.start() に渡すオプションに関する情報をこのファイルに隠蔽したかったからです。
今のところオプションを渡していないので少し冗長に見えるかも知れませんが、将来的にオプションの変更があったとき変更箇所をこのファイルに閉じることができるので、今のうちにラップしておいてもいいかなという考えでやってます。

src/mocks/server.ts

Jestで利用するサーバーを定義する場所です。
こちらもファイル名などはmswが用意している Integrate > Node のドキュメント に則っています。

// src/mocks/server.ts
import { setupServer, SetupServerApi } from "msw/node"
import { handlers } from "./handlers"

export * from "msw"

export const server: SetupServerApi = setupServer(...handlers)

src/mocks/handlers

setupWorkersetupServerに渡すhandlerを定義するところです。
src/mocks/handler/index.ts で下位のフォルダのhandlersをまとめます。

// src/mocks/handler/index.ts
import { handlers as hogeHandlers } from "./hoge/handlers"
import { handlers as fugaHandlers } from "./fuga/handlers"

export const handlers = [
  ...hogeHandlers,
  ...fugaHandlers,
]
// src/mocks/handler/hoge/handlers.ts
import { graphql } from "msw"
import { hoge } from "./data/hoge"

const hogeHandler = graphql.query<
  HogeQuery,
  HogeQueryVariables
>("Hoge", (req, res, ctx) => {
    return res(
      ctx.data({
        __typename: "Query",
        hoge: hoge,
      }),
    )
  },
)

const createHogeHandler = graphql.mutation<
  CreateHogeMutation,
  CreateHogeMutationVariables
>("CreateHoge", (req, res, ctx) => {
  // 作成処理は省略
  const createdHoge = ...省略
  return res(
    ctx.data({
      __typename: "Mutation",
      hoge: createdHoge,
    }),
  )
})

export const handlers = [
  hogeHandler,
  createHogeHandler,
]

handlerのレスポンスに利用するデータは近いところにあったほうが良いかなと思い src/mocks/handlers/hoge/data/ のように下位のhandlersと同じフォルダに置いています。
データの置き方については自分の中で「これ!」というものが定まっていないため、今後どういった構成がいいのか見直していくつもりです。

src/mocks/fixtures

ダミーの画像が入っています。
Binary response type のドキュメントを参考にしてこのフォルダを作りました。

mswを利用する側のコード

続いて定義したワーカーなどを利用する側のコードも見ていきます。

ローカル環境での利用

現在はローカル環境での開発には利用していません。
これは単に必要になっていないというだけなので、開発の仕方が変わっていけばローカル環境での開発にも利用するかも知れません。

Storybookでの利用

.storybook/preview.js で先ほど定義した startMockWorker 関数を呼び出すだけです。

// .storybook/preview.js
import { startMockWorker } from "../src/mocks/browser"

// 戻り値のPromiseは無視したいのでvoidをつけている
void startMockWorker()

...省略

export const decorators = [...省略]

Jestでの利用

jest.config.jssetupFilesAfterEnv に次のような設定をしておきます。

// jest.config.js
module.exports = {
  ...省略
  setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"]
}

用意した setupTests.ts でモックサーバーの listen などを呼び出します。

// src/setupTests.ts
import "@testing-library/jest-dom/extend-expect"
import { server } from "./mocks/server"

beforeAll(() => {
  server.listen()
})

afterEach(async () => {
  server.resetHandlers()
  jest.clearAllMocks()
})

afterAll(() => {
  server.close()
})

テストファイルごとに server.listen() server.close() などを書くのは面倒なので、あらかじめこのような設定をしておくとテストを書くのが楽になります。

本ブログ公開にあたって見直したところ

実はこのブログを公開する前のフォルダ構成は次のような形でした。

├── public
│    └── mockServiceWorker.js
├── src
│    ├── mocks
│    │    ├── fixtures
│    │    │    ├── image.jpg
│    │    ├── handlers
│    │    │    ├── hoge
│    │    │    ├── fuga
│    │    │    └── index.ts
│    │    ├── server
│    │    │    ├── index.ts
│    │    │    ├── devServer.ts
│    │    │    └── testServer.ts

現在との差分は次のとおりです

  • src/mocks/server というフォルダがある
  • src/mocks/server/devServer.tssetupWorker を利用
  • src/mocks/server/testServer.tssetupServer を利用
  • src/mocks/server/index.ts では実行している環境に合わせて、 devServer.tstestServer.ts をrequire

src/mocks/server/index.ts の具体的なコードはこのような形でした。

import { SetupWorkerApi } from "msw"
import { SetupServerApi } from "msw/node"

export * from "msw"

export let testServer: SetupServerApi | null = null
if (process.env.NODE_ENV === "test") {
  const { testServer: sever } = require("./testServer")
  testServer = sever
}

export let devServer: SetupWorkerApi | null = null
if (process.env.NODE_ENV === "storybook") {
  const { devServer: sever } = require("./devServer")
  devServer = sever
}

これはKent C. Doddsが Epic Reactのあるコースの中 でやっていた手法を真似たものでした。
導入当初はmswに対する知識がないため、こうするのが一般的なのかなと思っていました。
しかし、あとから見直してみると「requireを使っているため、CommonJSとES Modulesが混ざる形になっているのはやっぱり気持ち悪いな」とか「ワーカーとサーバーは利用用途が違うものなのに、ここで同じものとして扱って良いんだろうか?」と考えるようになりました。
結果的に src/mocks/server フォルダはなくなり、現在の形に落ち着きました。コードを見直すいい機会になってよかったなと思います。

おわりに

mswは導入が簡単で、加えてサービスにもたらす効果も高いという素敵なツールです。
皆さんもぜひ導入してみてください。

それでは、また。

参考・関連リンク