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 のインストールは公式ドキュメントの手順で完了します。
ドキュメントにある init コマンドを実行します。
$ npm init playwright@latest
ここでは上記コマンド実行時の質問に以下のように回答しています。
✔ 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 にある通りライブラリをインストールします。
各ライブラリはバージョンを指定してインストールします。
-
@playwright/test
:playwright
と同じバージョンを指定 -
msw
: v1 系を指定
$ npm i -D @playwright/test@1.42.1 msw@v1
msw の v1 指定について
執筆時点で Experimental test mode for Playwright で対応している MSW のバージョンは v1 でした。
そのため install する MSW も v1 系をインストールする必要があります。
そのまま npm i -D msw
を行うと執筆時点で最新の v2 系がインストールされるので注意しましょう。
v2 系と v1 系とでは MSW の API が全く異なるのでバージョンが違うと動作しません。
最終的にインストールされたパッケージのリスト
インストールされたパッケージは以下になります。
"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
というエラーが発生します
-
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 を書いてみる
とりあえずページを開いて screenshot を撮るテストを書いてみます。
テスト関連の test
や expect
は通常の playwright であれば @playwright/test
から import しますが、今回は next/experimental/testmode/playwright/msw
から import するようにします。
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 が得られます。
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 のドキュメントはこちら)
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/
に入れておきます。
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
に記述します。
export default defineConfig({
// 他の設定
// 👇 これを追記する
globalSetup: require.resolve("./tests/global-setup"),
});
一旦テストを動かしてみる
ここまでかけたところで一度テストを動かしてみます。
結果を見てみると、テキストの内容についてはモックした内容で表示されているのが確認できました!
しかし、画像が出ていませんし、フォントも違います。
テストを実行したコンソールをよく見てみると以下のエラーが出ていることがわかります。
[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 の実装を見てみると原因がわかります。
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 によるモック または msw によるモックがないリクエストは全て abort
で返すようになっているようです。
モックしないリクエストは passthrough する
モックがないことがエラーの原因のようでしたので、msw を使って元のリクエストへ飛ばすように passthrough してあげることにします。
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)
);
...
});
ではもう一度テストを実行してみましょう。
画像は表示されるようになりましたが、フォントはまだ当たっていないようです。
next/font をモックする
改めてコンソールを確認してみます。
[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 を ダミー画像と同じディレクトリに追加します。
そして、それをサーブするようにサーバーを書き換え、モックでそのサーバーに向けるようにします。
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);
}
...
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)
);
...
});
幾度目かのテスト実行
フォントも当たるようになりました!
完成
最後に screenshot を更新します。
$ npx playwright test --update-snapshots
成功したら再度テストを実行して成功するか確認しましょう。
無事モックしたテストが通ることが確認できました!
これで完成です。
この記事内容のリポジトリ
こちらにこの記事を書くにあたって作成したリポジトリはこちらです。
不明点がありましたら参照ください。
感想
サーバーサイドで動く API をどうモックすべきかとても悩んでいたところで Next.js が公式にこのような仕組みを出してくれたのはとても嬉しいことだと思う一方で、next/font 動かないのかよ!と思ったことがかなり印象に残っています。
全てのリクエストのモックを書かなければならないことや msw のバージョンが低いことなど他にも気になる点はある一方で、テスト用のメソッドを置き換えるだけでモックができるのはとても便利だと感じました。
このまま開発が進んでより良くなることを願っています!