🐥

MSWの活用まとめ

2023/12/31に公開

MSWをフル活用してNext.jsのプロダクトを開発していますが、その活用に関してまとめておきます。
前提として、Takapepeさんの記事に影響を受けており、本稿の前に以下に目を通していただけると理解が捗ると思います。
https://zenn.dev/takepepe/articles/nextjs-msw-hof

生成関数の準備

参考記事に紹介されていますが、モック生成の関数を用意します。

export const restHandlerFactory =
  <T extends DefaultBodyType, K extends PathParams, U extends DefaultBodyType>(
    method: keyof typeof rest,
    path: string,
    resolver?: ResponseResolver<RestRequest<T, K>, RestContext, U | ErrorResolverArgs>,
  ) =>
  (args?: {data?: U; err?: ErrorResolverArgs; status?: Status; mock?: jest.Mock}): RestHandler =>
    rest[method](path, (req: RestRequest<T, K>, res: ResponseComposition<U | any>, ctx: RestContext) => {
      if (args?.err) {
        args?.mock?.(args?.err)
        return delayedResponse(ctx.status(args?.err?.status ?? STATUS_CODES.BAD_REQUEST), ctx.json(args?.err))
      }

      if (method === 'get') {
        args?.mock?.(args?.data)
      } else {
        req.text().then((data) => {
          // dataが空の場合はnullを渡すことでparseエラーが起こらないようにする
          args?.mock?.(JSON.parse(data || 'null'))
        })
      }

      if (resolver) return resolver(req, res, ctx)
      return delayedResponse(ctx.status(args?.status ?? STATUS_CODES.OK), ctx.json(args?.data))
    })

紹介されていた関数から以下に関しては変更を加えています。

  • resolverをオプショナルにする
    • 通常は正常終了でレスポンスデータを返せば良いため、呼び出し元の手間を軽減しています。
    • resolverを活用するケースは後述します。
  • レスポンスを遅延返却する
    • delayedResponseとなっている箇所ですが、ローカル実行の場合はローディングなどが本番相当で出るようにするために、遅延返却にしています。
delayedResponseのコード
const isTesting = ENV === 'test' || STORYBOOK_ENV === 'storybook'
export const delayedResponse = createResponseComposition(undefined, [context.delay(isTesting ? 0 : 300)])

handlerの設定

基本的なhandlerの設定

handlersディレクトリの中に、一定のまとまりごとにファイルを作成します。
例えばマスタデータ取得をひとまとまりとして、master.tsを作成。
そこで、それぞれのAPIごとにhandlerを作成します。

master.ts
export const createMasterXXXFetchHandler = restHandlerFactory<
  undefined,
  Record<string, string>,
  XXXMasterList
>('get', XXX_API_URL.MASTER_XXX) // methodとURLを指定

resolverを活用したhandlerの設定

クエリパラメータに応じたレスポンスを返却したい場合に、resolverを設定します。
以下はテストデータ(XXXMaster)からクエリパラメータでフィルターしたリストに絞ったレスポンスを返却しています。

master.ts
export const createMasterXXXFetchHandler = restHandlerFactory<undefined, Record<string, string>, SchoolMasterList>(
  'get',
  RESUME_API_URL.MASTER_SCHOOL,
  (req, res, ctx) => {
    const q = req.url.searchParams.get('q')
    let xxxxs = XXXMaster
    if (q) {
      xxxxs = xxxxs.filter(
        (xxxx) =>
          xxxx.name.toLowerCase().includes(q.toLowerCase().trim()) ||
          xxxx.kana.toLowerCase().includes(q.toLowerCase().trim()) ||
          xxxx.kana.toLowerCase().includes(hiraganaToKatakana(q).toLowerCase().trim()) ||
          xxxx.english.toLowerCase().includes(q.toLowerCase().trim()),
      )
    }

    return delayedResponse(ctx.status(STATUS_CODES.OK), ctx.json({items: xxxxs}))
  },
)

handlerの集約

定義したhandlerをまとめたhandlersを定義します。

master.ts
export const masterGetHandlers = [
  createMasterXXXFetchHandler({data: XXXMaster}),
  ・・・・・,
]

masterGetHandlersなどそれぞれにまとめたhandlerを集約したhandlerを作成します。

index.ts
export const handlers = [
  ...masterGetHandlers,
  ...userGetHandlers,
  ...userPutHandlers,
  ・・・・・・・・
]

集約したhandlerを設定

server.ts
export const server = require('msw/node').setupServer(...handlers)
browser.ts
export const worker = require('msw').setupWorker(...handlers)
ローカル読み込み用関数
index.ts
export const initMocks = (): void => {
  // Next.jsはクライアントサイドでもサーバサイドでも実行されるため、双方の考慮を入れる
  if (typeof window === 'undefined') {
    const {server} = require('./server')
    server.listen()
  } else {
    const {worker} = require('./browser')
    worker.start({
      onUnhandledRequest(request: MockedRequest, print: any) {
        if (!request.url.pathname.startsWith('/_next') && !(request.url.pathname === '/img/logo/favicon.ico')) {
          print.warning()
        }
      },
    })
  }
}
テストのセットアップ
jest.setup.ts
beforeAll(() => server.listen())
afterAll(() => server.close())
afterEach(() => {
  server.resetHandlers()
  queryClient.clear() // tanstack-queryの設定
})
storybookセットアップ
preview.js
import {initialize, mswDecorator} from 'msw-storybook-addon'
initialize()
export const decorators = [mswDecorator]

各storyでmswを設定

export default {
  component: XXXXTemplate,
  parameters: {
    layout: 'fullscreen',
    msw: {
      handlers, // index.tsのhandlersを読み込み。画面によっては必要なものだけ読み込む形に変える
    },
  },
  decorators: [
    (StoryFn): JSX.Element => (
      <Layout>
        <StoryFn />
      </Layout>
    ),
  ],
} as Meta<typeof XXXXTemplate>

テストでの活用方法

画面の保存処理の正常系

tanstack-queryも活用しています。

it('保存処理が呼ばれキャッシュがクリアされること', async () => {
    // Given
    const mockCallback = jest.fn()
    server.use(createXXXPutHandler({mock: mockCallback})) // server.tsから
    const {result: queryClientResult} = renderHook(() => useQueryClient(), {wrapper: createQueryClientWrapper()})
    const invalidateQueriesSpy = jest.spyOn(queryClientResult.current, 'invalidateQueries')
    render(<XXX />, {wrapper: createQueryClientWrapper()})
    const titleInput = (await screen.findAllByRole('textbox'))[0]
    const yearInput = screen.getAllByRole('spinbutton')[0]
    const descriptionInput = screen.getAllByRole('textbox')[1]
    // When
    await userEvent.type(titleInput, 'タイトル')
    await userEvent.type(yearInput, '2021')
    await userEvent.type(descriptionInput, '概要')
    await userEvent.click(screen.getByRole('button', {name: '次へ'}))
    // Then
    await waitFor(() => expect(mockCallback).toHaveBeenCalledTimes(1))
    await waitFor(() =>
      expect(mockCallback).toHaveBeenCalledWith({
        items: [
          {
            title: 'タイトル',
            year: 2021,
            description: '概要',
          },
        ],
      }),
    )
    await waitFor(() => expect(invalidateQueriesSpy).toHaveBeenCalledTimes(1))
  })

createQueryClientWrapperに関して
queryClientWrapper.tsx
import {QueryClientProvider} from '@tanstack/react-query'
// optionsやqueryCacheが設定されたQueryClient
import {queryClient as defaultQueryClient} from '@/libs/react-query'

export type WrapperType = {
  children: ReactNode
}

export const createQueryClientWrapper =
  (queryClient = defaultQueryClient) =>
  ({children}: WrapperType): ReactNode => <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>

toHaveBeenCalledWithを活用することで、入力した値がリクエストパラメータとして連携されていることを担保しています。

レスポンスをエラーにしたい場合

以下のようにerrを指定します。

createXXXFetchHandler({err: {status: STATUS_CODES.NOT_FOUND}})

ローカルでの起動に関して

上述したinitMocksを_app.tsxで読み込みます。
その際、mockの使用可否をコマンドで切り替えられるように環境変数を活用します。

_app.tsx
if (API_MOCKING === 'true') initMocks()
package.json
    "dev": "NEXT_PUBLIC_API_MOCKING=true run-p dev:next",
    "dev:next": "next dev",
    "dev:api": "NEXT_PUBLIC_API_MOCKING=false next",

まとめ

App RouterだとMSWが動かない状況ですが、Page Routerでの開発ではMSWは非常に強力なライブラリです。
ローカル、テスト、ストーリーブックを円滑に開発していくために、導入を検討してみてはいかがでしょうか。

Discussion