Next.js で Server Component で fetch している API をモックし VRT するために Experimental test mode for Playwright を試してみる

Next.js で E2E Test や VRT (Visual Regression Test) を行う時に API の Mock をどうするべきかという問題にしばしば当たります。

特に App Router における Server Components や Page Router における getServerSideProps などサーバーサイドでリクエストされる API の Mock は一筋縄ではできません。

そこで Experimental test mode for Playwright の登場です。
これは Next.js に内包されている機能で、Playwright と MSW をラップしてくれています。
それによりサーバーサイドの fetch request を mock できる様にしてくれます。

この記事では Next.js の Experimental test mode for Playwright を使って、VRT をするまでに行ったこと・躓いたことを書いていこうと思います。

# 環境・ライブラリのインストール

Next.js の App Router を使ったアプリケーションが既にある想定です。
**また Next.js のバージョンは執筆時点で最新の v14.1.4 を使用しています。**

**node のバージョンは v18 以上を使用してください。**
**この記事では v21.4.0 で動作確認しています。**

## Playwright をインストールする

_Playwright が既に動作している場合はこのセクションは飛ばして OK です。_

Playwright のインストールは公式ドキュメントの手順で完了します。

https://playwright.dev/docs/intro#running-the-example-test

ドキュメントにある init コマンドを実行します。

```shell
$ npm init playwright@latest
```

ここでは上記コマンド実行時の質問に以下のように回答しています。

```shell
✔ Where to put your end-to-end tests? · tests
✔ Add a GitHub Actions workflow? (y/N) · false
✔ Install Playwright browsers (can be done manually via 'npx playwright install')? (Y/n) · true
```

インストールが完了したら以下のコマンドでサンプルのテストが通ることを確認しましょう。

```
$ npx playwright test
```

エラーが出なければ OK です。

## Experimental test mode for Playwright に必要なライブラリをインストールする

まずは README にある通りライブラリをインストールします。

https://github.com/vercel/next.js/tree/v14.1.4/packages/next/src/experimental/testmode/playwright

各ライブラリはバージョンを指定してインストールします。

- `@playwright/test`: `playwright` と同じバージョンを指定
- `msw`: v1 系を指定

```shell
$ npm i -D @playwright/test@1.42.1 msw@v1
```

:::note warn
**msw の v1 指定について**
執筆時点で Experimental test mode for Playwright で対応している MSW のバージョンは v1 でした。
そのため install する MSW も v1 系をインストールする必要があります。
そのまま `npm i -D msw` を行うと執筆時点で最新の v2 系がインストールされるので注意しましょう。
v2 系と v1 系とでは MSW の API が全く異なるのでバージョンが違うと動作しません。
:::

## 最終的にインストールされたパッケージのリスト

インストールされたパッケージは以下になります。

```json:package.json
  "dependencies": {
    "next": "14.1.4",
    "react": "^18",
    "react-dom": "^18"
  },
  "devDependencies": {
    "@playwright/test": "^1.42.1",
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "eslint": "^8",
    "eslint-config-next": "14.1.4",
    "msw": "^1.3.3",
    "typescript": "^5"
  }
```

# `playwright.config.ts` を書き換える

こちらも Experimental test mode の README にあるように書き換えます。
一部改変を入れています。

- `defineConfig` を `next/experimental/testmode/playwright` から import したものに変更
- `webServer.command` 部分で next 起動前に **cache を削除する**ようにする
  - `.next/cache` に fetch の cache などが入っています。cache があることに気付けず思わぬ沼にハマることになるので、起動の度に消えるようにしておきます。
- `webServer.command` 部分で next 起動コマンドに `--experimental-test-proxy` オプションを渡す
- `webServer.port` にポート番号を指定
  - `webServer.url` ではテスト実行時に `Error: No test info for xxx` というエラーが発生します

```ts:playwright.config.ts
import { defineConfig, devices } from "next/experimental/testmode/playwright";
import { SITE_PORT } from "./tests/constants";

export default defineConfig({
  testDir: "./tests",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: "html",
  use: {
    baseURL: "http://127.0.0.1:3000",
    trace: "on-first-retry",
  },

  projects: [
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },
    },
    {
      name: "firefox",
      use: { ...devices["Desktop Firefox"] },
    },
    {
      name: "webkit",
      use: { ...devices["Desktop Safari"] },
    },
  ],

  webServer: {
    command: `rm -rf .next && npx next dev -p ${SITE_PORT} --experimental-test-proxy`,
    port: SITE_PORT,
    reuseExistingServer: !process.env.CI,
  },
});
```

# テストを書く

テストの設定ができたところで早速テストを書いていこうと思います。

今回は MSW による API のモックを試したいので雑に Qiita 記事の一覧を表示するページを作成しました。
Qiita API からユーザー情報の取得と記事一覧を取得しています。
またフォントは next/font を使って NotoSansJP を当てています。

このページに対して VRT を行います。

![VRT対象のページ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3584802/718bab94-f2ac-2375-49ba-cca2c3249f08.png)

## とりあえずページの VRT を書いてみる

とりあえずページを開いて screenshot を撮るテストを書いてみます。

テスト関連の `test` や `expect` は通常の playwright であれば `@playwright/test` から import しますが、今回は `next/experimental/testmode/playwright/msw` から import するようにします。

```ts:tests/top.spec.ts
import { test, expect } from "next/experimental/testmode/playwright/msw";

test("/ SnapShot", async ({ page }) => {
  await page.goto(".");
  await page.waitForLoadState();
  await expect(page).toHaveScreenshot("top.png", { fullPage: true });
});
```

このテストを実行すると以下のような screenshot が得られます。

![Playwright によるスクリーンショット](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3584802/7653fcb4-fe86-f59b-083b-520e90366dfc.png)

:::note
`npx playwright test` は screenshot が存在しない場合、エラーになりますが screenshot を生成してくれます。
既に screenshot がある場合は `npx playwright test --update-snapshots` で screenshot を更新できます。
:::

## API をモックしてみる

ここでようやく本題に入ります。
Experimental test mode for Playwright を使って Qiita API をモックしデータを固定します。

`test` を `next/experimental/testmode/playwright/msw` から import していると `MswFixture` が使えるようになります。

あとは msw の作法に従ってモックしていきます。

_msw が v1 系なことに注意です。（[v1 のドキュメントはこちら](https://v1.mswjs.io/))_

```ts:tests/top.spec.ts
import { test, expect, rest } from "next/experimental/testmode/playwright/msw";
import { ASSET_SERVER_URL } from "./constants";

const genItem = (id: number) => ({
  id: String(id),
  title: `Title ${id}`,
  updated_at: "2024-03-31T20:00:00+09:00",
  url: `https://example.com/${id}`,
});

test("/ SnapShot", async ({ page, msw }) => {
  msw.use(
    rest.get("https://qiita.com/api/v2/users/:id", async (req, res, ctx) => {
      const id = req.params.id as string;
      return res(
        ctx.status(200),
        ctx.json({
          id,
          profile_image_url: `${ASSET_SERVER_URL}/profile.png`,
        })
      );
    }),
    rest.get("https://qiita.com/api/v2/items", async (req, res, ctx) => {
      const posts = [...Array(4)].map((_, i) => genItem(i + 1));
      return res(ctx.status(200), ctx.json(posts));
    })
  );

  await page.goto(".");
  await page.waitForLoadState();
  await page.waitForTimeout(1000);
  await expect(page).toHaveScreenshot("top.png", { fullPage: true });
});
```

ユーザーと記事の取得部分をモックしました。

ユーザーのプロフィール画像である `profile_image_url` に注目してください。
画像についても可能であればダミー画像を用意したいところです。
そこでテスト実行時にダミー画像を返すサーバーを立てることにします。

## ダミー画像を返すためのサーバーをテスト実行時のみ立てる

playwright の config には `globalSetup` という項目があります。
ここにはセットアップ用のスクリプトを渡すことができます。

ここで用意したダミー画像を返すサーバーを立てることにします。
またダミー画像は `tests/assets/` に入れておきます。

```ts:tests/global-setup.ts
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import { ASSET_SERVER_PORT } from "./constants";

export default async function globalSetup() {
  const server = http.createServer((req, res) => {
    const paths = req.url?.split("/");

    if (paths?.[1] === "profile.png") {
      res.writeHead(200, {
        "Content-Type": "image/png",
      });
      const img = fs.readFileSync(
        path.resolve(__dirname, "./assets/profile.png")
      );
      res.end(img, "binary");
      return;
    }

    res.writeHead(404, { "Content-Type": "text/plain" });
    res.end();
  });
  server.listen(ASSET_SERVER_PORT);
}
```

とりあえず特定のパスを叩いた時に画像を返すように設定しました。
先ほどのモックの画像のパスはここで立ち上げたサーバーの URL を指定しています。

このスクリプトを実行するように `playwright.config.ts` に記述します。

```ts:playwright.config.ts
export default defineConfig({
  // 他の設定

  // 👇 これを追記する
  globalSetup: require.resolve("./tests/global-setup"),
});
```

## 一旦テストを動かしてみる

ここまでかけたところで一度テストを動かしてみます。

![テスト結果001](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3584802/67cd89e3-5c20-2004-8b86-5dfc093a892a.png)

結果を見てみると、テキストの内容についてはモックした内容で表示されているのが確認できました！

しかし、画像が出ていませんし、フォントも違います。
テストを実行したコンソールをよく見てみると以下のエラーが出ていることがわかります。

```shell
[WebServer] FetchError: request to https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap failed, reason: Proxy request aborted [GET https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap]

...

[WebServer] Error: Proxy request aborted [GET http://localhost:3100/profile.png]
```

これは Experimental test mode for Playwright の実装を見てみると原因がわかります。

> https://github.com/vercel/next.js/blob/v14.1.4/packages/next/src/experimental/testmode/playwright/msw.ts#L109

```ts:next.js/packages/next/src/experimental/testmode/playwright/msw.ts より
        if (isUnhandled) {
          return undefined
        }
        if (isPassthrough) {
          return 'continue'
        }

        if (mockedResponse) {
          const {
            status,
            headers: responseHeaders,
            body: responseBody,
            delay,
          } = mockedResponse
          if (delay) {
            await new Promise((resolve) => setTimeout(resolve, delay))
          }
          return new Response(responseBody, {
            status,
            headers: new Headers(responseHeaders),
          })
        }

        return 'abort'
```

msw を使わない [next によるモック](https://github.com/vercel/next.js/tree/v14.1.4/packages/next/src/experimental/testmode/playwright#use-the-nextexperimentaltestmodeplaywright-to-create-tests) または msw によるモックがないリクエストは全て `abort` で返すようになっているようです。

## モックしないリクエストは passthrough する

モックがないことがエラーの原因のようでしたので、msw を使って元のリクエストへ飛ばすように passthrough してあげることにします。

```ts:tests/top.spec.ts
import {
  test,
  expect,
  rest,
  ResponseResolver,
} from "next/experimental/testmode/playwright/msw";

...

const passthroughHandler: ResponseResolver = (req) => req.passthrough();

test("/ SnapShot", async ({ page, msw }) => {
  msw.use(
    ...

    // 以下の二つを追加
    rest.get(`${ASSET_SERVER_URL}/*`, passthroughHandler),
    rest.get("https://fonts.googleapis.com", passthroughHandler)
  );

  ...
});
```

ではもう一度テストを実行してみましょう。

![テスト結果002](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3584802/b3504831-a33a-46d8-4075-b54d5faadc99.png)

画像は表示されるようになりましたが、フォントはまだ当たっていないようです。

## next/font をモックする

改めてコンソールを確認してみます。

```shell
[WebServer] Invalid response body while trying to fetch https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap: incorrect header check
```

何やらエラーが出ています、これについてはいい解決方法が思いつきませんでしたので、力技で解決することにしました。(いい方法を知っている方がいましたら是非教えてください・・・！)

エラーで指摘されている URL にアクセスし中身をコピーした css を ダミー画像と同じディレクトリに追加します。
そして、それをサーブするようにサーバーを書き換え、モックでそのサーバーに向けるようにします。

```ts:tests/global-setup.ts
export default async function globalSetup() {
  const server = http.createServer((req, res) => {
    const paths = req.url?.split("/");

    if (paths?.[1] === "profile.png") {
      ...

    // css を返すようにする
    } else if (paths?.[1] === "noto_sans_jp.css") {
      res.writeHead(200, {
        "Content-Type": "text/css",
      });
      const css = fs.readFileSync(
        path.resolve(__dirname, "./assets/noto_sans_jp.css")
      );
      res.end(css, "utf-8");
      return;
    }

    ...
  });
  server.listen(ASSET_SERVER_PORT);
}
```

```ts:tests/top.spec.ts
...

test("/ SnapShot", async ({ page, msw }) => {
  msw.use(
    ...

    // google font へのアクセスをモックして先ほどのサーバーからデータを取得します
    rest.get("https://fonts.googleapis.com/*", async (req, res, ctx) => {
      const data = await fetch(`${ASSET_SERVER_URL}/noto_sans_jp.css`);
      const css = await data.text();
      return res(ctx.status(200), ctx.text(css));
    }),
    // 👆 だけでは以下のリクエストが abort してしまうので passthrough できるように追加しています
    rest.get("https://fonts.gstatic.com/*", passthroughHandler)
  );

  ...
});
```

幾度目かのテスト実行

![テスト結果003](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3584802/bdc8622e-afb4-8803-2ac7-7fb2ce442a84.png)

フォントも当たるようになりました！

## 完成

最後に screenshot を更新します。

```shell
$ npx playwright test --update-snapshots
```

成功したら再度テストを実行して成功するか確認しましょう。

![最終テスト結果](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3584802/c1a24dc0-be12-288c-8f0e-3dad6f20f6a8.png)

無事モックしたテストが通ることが確認できました！
これで完成です。

# この記事内容のリポジトリ

こちらにこの記事を書くにあたって作成したリポジトリはこちらです。
不明点がありましたら参照ください。

https://github.com/shilaca/next-testmode

# 感想

サーバーサイドで動く API をどうモックすべきかとても悩んでいたところで Next.js が公式にこのような仕組みを出してくれたのはとても嬉しいことだと思う一方で、next/font 動かないのかよ！と思ったことがかなり印象に残っています。
全てのリクエストのモックを書かなければならないことや msw のバージョンが低いことなど他にも気になる点はある一方で、テスト用のメソッドを置き換えるだけでモックができるのはとても便利だと感じました。
このまま開発が進んでより良くなることを願っています！