Open6

Next.js + MSW + Playwright を真面目に考えてみる

うじまるうじまる

モチベーション

  • E2E テストをしたい
  • mutation 系もテストをしたいので mock を使いたい
  • unit test で既に MSW を使っているので mock は MSW を使いたい
うじまるうじまる

Next.js + Playwright + MSW の選択肢

一応 Next.js 側で next/experimental/testmode/playwrightnext/experimental/testmode/playwright/msw という機能を export している
これは、playwright で next を実行したときに fetch に intercept してくれる Fixture を提供してくれる。
/msw の方は fetch の intercept に msw の handler を使える

next/experimental/testmode/playwright/msw

現状だと msw v1 系にしか対応していないので msw v2 系 を利用している場合は使えない、残念
なので next/experimental/testmode/playwright をベースに自分で msw v2 に対応した fixture を作る

type MswFixture = {
  use: (...handlers: RequestHandler[]) => void
}
export const test = base.extend<{
  msw: MswFixture
  mswHandlers: RequestHandler[]
}>({
  mswHandlers: [handlers, { option: true }],
  msw: [
    async ({ next, mswHandlers }, use) => {
      const handlers: RequestHandler[] = [...mswHandlers]
      const emitter = new Emitter<LifeCycleEventsMap>()

      next.onFetch(async (request) => {
        let isPassthrough = false
        let mockedResponse: Response | null = null

        await handleRequest(
          request,
          Math.random().toString(16).slice(2),
          handlers,
          {
            onUnhandledRequest: () => {
              isPassthrough = true
            },
          },
          emitter,
          {
            onPassthroughResponse: () => {
              isPassthrough = true
            },
            onMockedResponse: (r) => {
              mockedResponse = r
            },
          },
        )

        if (isPassthrough) {
          return 'continue'
        }
        if (mockedResponse) {
          return mockedResponse
        }

        return 'abort'
      })

      await use({
        use: (...newHandlers) => {
          handlers.unshift(...newHandlers)
        },
      })

      handlers.length = 0
    },
    { auto: true },
  ],
})
うじまるうじまる

msw v2 は Request/Response に対応したので msw v1 の fixture でやってる Request の詰め替えとかはやらなくても大丈夫になった

本家の v1 の fixture とやや違うのは UnhandledRequest を Passthrough と同じ挙動にしていること、Unhandled で止めてしまうと外部アクセス(Firebase Auth とか)で abort してしまうのでこのような対応にした

msw の handleRequest 自体が公式にドキュメントがないのでアレだが、コード上から msw を使うときに使えるっぽい

うじまるうじまる

UnhandledRequest を Passthrough を continue にしていたが、なんかうまくいかなかったので自分で fetch をしてバイパスすることにした。

onFetchabort とか continue とか undefined | null の挙動ってどこかに書いてるのかな...

 type MswFixture = {
  use: (...handlers: RequestHandler[]) => void
}
export const test = base.extend<{
  msw: MswFixture
  mswHandlers: RequestHandler[]
}>({
  mswHandlers: [handlers, { option: true }],
  msw: [
    async ({ next, mswHandlers }, use) => {
      const handlers: RequestHandler[] = [...mswHandlers]
      const emitter = new Emitter<LifeCycleEventsMap>()

      next.onFetch(async (request) => {
        let isPassthrough = false
        let mockedResponse: Response | null = null

        await handleRequest(
          request.clone(),
          Math.random().toString(16).slice(2),
          handlers,
          {
            onUnhandledRequest: () => {
              isPassthrough = true
            },
          },
          emitter,
          {
            onPassthroughResponse: () => {
              isPassthrough = true
            },
            onMockedResponse: (r) => {
              mockedResponse = r
            },
          },
        )

        if (isPassthrough) {
          return fetch(request.clone())
        }
        if (mockedResponse) {
          return mockedResponse
        }

        return 'abort'
      })

      await use({
        use: (...newHandlers) => {
          handlers.unshift(...newHandlers)
        },
      })

      handlers.length = 0
    },
    { auto: true },
  ],
})
うじまるうじまる

追加でちょっと詰まった部分

playwright では script 部分の実行が CJS を解釈するようになっているので ESM の場合はうまく読み込まれないよう
ここで言う script というのは、今回で言う MSW の handler とか test(..., () => {...}) を書く部分。つまり、playwright の worker や test runner が実行するコードのこと。
今回は、lodash-es を使っている部分でこのエラーが出てしばらく詰まってた(zip だけ使いたかったので仕方なく lodash.zip を入れて対応した)

ref: https://github.com/microsoft/playwright/issues/23662

うじまるうじまる

少し詰まった部分

test.beforeEach(async ({ page }) => {
  await page.goto('/xxx')
})

test('hoge', async ({ page, msw }) => {
  msw.use(...)
  
  await page...
})

のように書いたときに、hoge の msw handler の設定がされていなかった
それはそう、という感じだが page.goto が終わってから msw.use になるのでページ描画に必要なAPIの設定がされないままページが描画されるという形だった
この場合

test('hoge', async ({ page, msw }) => {
  msw.use(...)

  await page.goto('/xxx')  
  await page...
})

としないといけない