WireMock を Playwright(JS)で使う
目的
Next.js や SvelteKit のような専用サーバーを配置するフロントエンドアプリの統合テストのために、バックエンドAPIのモックサーバーを用意したい。
調べたところ WireMock と Mockoon が良さそうだったのでそれぞれを利用してどんなことができるかをまとめる。
調査ポイント
- MSWの様にテストケースごとにレスポンスを変えたりできるか?
- レスポンスの値による境界分割や同値分割、あるいはデシジョンテーブルのようなテストに対応できるか?
- 同じリクエストでも 成功時とエラー時のハンドリングをテストしたいため
- CIでPlaywright で フロントエンドアプリをテストするときに使いやすいか?
- 今回はGitHub Actionsで実行できることを想定
- ローカル環境でテストできることも考えておく
- REST API (OpenAPI)で確認する
- 今回は以前作ったSvelteKitで調査する
製品のHP
WireMock
Mockoon
最初にWireMockから。(というか、ドキュメントを読んだ感じWireMockがやりたいことを実現できそう)
WireMockのStandaloneサーバーをDockerで起動する。
docker run -it --rm -p 8080:8080 --name wiremock wiremock/wiremock
今回は SvelteKitアプリなのでテストフレームワークに Vitest Playwright を使用する。 Vitest Playwright を実行するNode.jsでWireMock APIにアクセスするために、WireMock Captain を依存関係に追加する。
npm install --save-dev wiremock-captain
次のようなテストPlayWrightを使って実行する。ちなみに サンプルは Jest本 を元にSvelteKit + Vitest で作成したもの
test.describe("ページコンポーネントのAction操作", () => {
test("所得税を計算できる", async ({ page }) => {
// Begin
const wiremockEndpoint = "http://localhost:8080";
const mock = new WireMock(wiremockEndpoint);
const request: IWireMockRequest = {
method: "POST",
endpoint: "/calc-tax",
body: {
yearsOfService: 10,
isDisability: false,
isOfficer: false,
severancePay: 5000000
}
};
const mockedResponse: IWireMockResponse = {
status: 200,
body: {
tax: 25525
}
};
await mock.register(request, mockedResponse);
await page.goto("http://localhost:4173/");
// When
await page.getByRole("spinbutton", { name: "勤続年数" }).click();
await page.getByRole("spinbutton", { name: "勤続年数" }).fill("10");
await page.getByRole("spinbutton", { name: "退職金" }).click();
await page.getByRole("spinbutton", { name: "退職金" }).fill("5000000");
await page.getByRole("button", { name: "所得税を計算する" }).click();
// Then
await expect(page.getByLabel("tax")).toHaveText("25,525 円");
});
});
テストを実行してみるとコネクションエラー発生。。。
SvelteKit側で バックエンドAPIサーバーの ポートを 3000 固定にしていたので、WireMockコンテナもこれに合わせなければいけなかったので、ポート指定を修正してコンテナを再度立ち上げ直す
docker run -it --rm -p 3000:8080 --name wiremock wiremock/wiremock
テストコードのエンドポイントも修正 const wiremockEndpoint = "http://localhost:3000";
で、テストを実行すると、成功した!
次にエラーハンドリングをテストしてみる。WireMockでResponseが変わることを確認したいので、リクエストは先ほどと同じ内容にし、結果だけが異なるように書く。
test.describe("ページコンポーネントのAction操作", () => {
const wiremockEndpoint = "http://localhost:3000";
const mock = new WireMock(wiremockEndpoint);
const request: IWireMockRequest = {
method: "POST",
endpoint: "/calc-tax",
body: {
yearsOfService: 10,
isDisability: false,
isOfficer: false,
severancePay: 5000000
}
};
test("所得税を計算できる", async ({ page }) => {
// Begin
const mockedResponse: IWireMockResponse = {
status: 200,
body: {
tax: 25525
}
};
await mock.register(request, mockedResponse);
await page.goto("http://localhost:4173/");
// When
await page.getByRole("spinbutton", { name: "勤続年数" }).click();
await page.getByRole("spinbutton", { name: "勤続年数" }).fill("10");
await page.getByRole("spinbutton", { name: "退職金" }).click();
await page.getByRole("spinbutton", { name: "退職金" }).fill("5000000");
await page.getByRole("button", { name: "所得税を計算する" }).click();
// Then
await expect(page.getByLabel("tax")).toHaveText("25,525 円");
});
test("APIからのステータスコードが200-209以外の場合", async ({ page }) => {
// Begin
const mockedResponse: IWireMockResponse = {
status: 400,
body: {
message: "Invalid parameter."
}
};
await mock.register(request, mockedResponse);
await page.goto("http://localhost:4173/");
// When
await page.getByRole("spinbutton", { name: "勤続年数" }).click();
await page.getByRole("spinbutton", { name: "勤続年数" }).fill("10");
await page.getByRole("spinbutton", { name: "退職金" }).click();
await page.getByRole("spinbutton", { name: "退職金" }).fill("5000000");
await page.getByRole("button", { name: "所得税を計算する" }).click();
// Then
await expect(page.getByText("エラーが発生しました。しばらくしてからもう一度お試しください。")).toBeVisible();
});
});
エラーハンドリングの方のテストが失敗する。調べたらそもそも プロダクションコード側で APIサーバーからのエラーを処理していなかったので、付け足した。
import { fail } from "@sveltejs/kit";
import { superValidate } from "sveltekit-superforms/server";
import type { CalcTaxResult } from "$lib/fetch/client/calcTax";
import { calcTax } from "$lib/fetch/client/calcTax";
import type { InputSchema } from "$lib/schemas/inputSchema";
import { inputSchema } from "$lib/schemas/inputSchema";
import type { Actions, PageServerLoad } from "./$types";
export const load = (async () => {
const form = await superValidate(inputSchema);
return { form };
}) satisfies PageServerLoad;
export const actions = {
default: async ({ request }) => {
const form = await superValidate(request, inputSchema);
if (!form.valid) {
return fail(400, { form, tax: 0 });
}
const param = form.data as InputSchema;
const response = await calcTax(param);
let tax = 0;
if (response.ok) {
const json = (await response.json()) satisfies CalcTaxResult;
tax = json.tax;
} else {
return fail(400, { form, tax: 0 });
}
return { form, tax };
}
} satisfies Actions;
すると、エラーの方も成功した。
むしろ、これまでのテストコードではAPIからのエラーハンドリングの検証ができていなかったので、この部分のテストコードを書くためにも APIサーバーのモック化は大事だと改めて思った。(API側で意図的にエラーを出すのは準備が結構大変だと思うし)
WireContainerをTestcontainersで起動しテストコードを実行することで完結させたい。
npm install --save-dev testcontainers
テストコードで以下のようにTestcontaiersでwiremockを立ち上げる
test.describe("ページコンポーネントのAction操作", () => {
let startedContainer: StartedTestContainer;
let mock: WireMock;
test.beforeAll(async () => {
const container = await new GenericContainer("wiremock/wiremock").withExposedPorts(8080);
startedContainer = await container.start();
const wiremockEndpoint = "http://localhost:3000";
mock = new WireMock(wiremockEndpoint);
});
test.afterAll(async () => {
await startedContainer.stop();
});
const request: IWireMockRequest = {
method: "POST",
endpoint: "/calc-tax",
body: {
yearsOfService: 10,
isDisability: false,
isOfficer: false,
severancePay: 5000000
}
};
test("所得税を計算できる", async ({ page }) => {
...
)};
)};
テスト実行時に起動はしているのだが、Testcontainersから起動したコンテナのホスト側のポートはランダムになるため、http://localhost:3000
ではモックAPIサーバーにアクセスできなかった。
このため2つの方法を検討する。
- アプリ側でコンテナのランダムポートでアクセスできる様にする
- Testcontainers側のホストポートを指定できる様にする
アプリ側でTestcontainersで生成したコンテナのポートにアクセスするためにはまず、WireMockコンテナの起動
-> SvelteKitサーバーのビルドと起動
としなければならなさそう。理由は SvelteKitアプリをビルドしたときに環境変数を読み込むことになるため。
ただ、Playwrightを使っていると、Playwrightを起動したときに SvelteKitアプリのビルドとサーバー起動を行ってからテストを実行するので、どうしてもコンテナの起動の方が後になってしまう?
SvelteKitの $env/dynamic/privateに注入できないかも考えているけど、Playwrightで起動したサーバーにどのようにアクセスして環境変数を定義すれば良いか不明。
Testcontainerで起動するWireMockのホスト側ポートを固定にする方法も Java向けだと withCreateContainerCmdModifier()
を使ってポートバインディングする方法が例示されているんだけど、Node向けには同等のメソッドが提供されていない。
TestcontainersでWireMockを起動してテストする方法は諦めて、CIでコンテナを用意することにする。
ローカルでテストする時には事前にWireMockを起動する。
docker-compose からTestcontainersを起動する様にしたらポートを指定してテストすることができたっぽい
services:
wire-mock:
image: wiremock/wiremock
container_name: wiremock
environment:
TZ: Asia/Tokyo
ports:
- 3000:8080
import { expect, test } from "@playwright/test";
import { DockerComposeEnvironment, StartedDockerComposeEnvironment } from "testcontainers";
import type { IWireMockRequest, IWireMockResponse } from "wiremock-captain";
import { WireMock } from "wiremock-captain";
test.describe("ページコンポーネントのAction操作", () => {
let environment: StartedDockerComposeEnvironment;
let mock: WireMock;
test.beforeAll(async () => {
environment = await new DockerComposeEnvironment("./tests", "compose.yml").up();
const wiremockEndpoint = "http://localhost:3000";
mock = new WireMock(wiremockEndpoint);
});
test.afterAll(async () => {
await environment.down();
});
const request: IWireMockRequest = {
method: "POST",
endpoint: "/calc-tax",
body: {
yearsOfService: 10,
isDisability: false,
isOfficer: false,
severancePay: 5000000
}
};
test("所得税を計算できる", async ({ page }) => {
// Begin
const mockedResponse: IWireMockResponse = {
status: 200,
body: {
tax: 25525
}
};
await mock.register(request, mockedResponse);
await page.goto("http://localhost:4173/");
// When
await page.getByRole("spinbutton", { name: "勤続年数" }).click();
await page.getByRole("spinbutton", { name: "勤続年数" }).fill("10");
await page.getByRole("spinbutton", { name: "退職金" }).click();
await page.getByRole("spinbutton", { name: "退職金" }).fill("5000000");
await page.getByRole("button", { name: "所得税を計算する" }).click();
// Then
await expect(page.getByLabel("tax")).toHaveText("25,525 円");
});
test("APIからのステータスコードが200-209以外の場合", async ({ page }) => {
// Begin
const mockedResponse: IWireMockResponse = {
status: 400,
body: {
message: "Invalid parameter."
}
};
await mock.register(request, mockedResponse);
await page.goto("http://localhost:4173/");
// When
await page.getByRole("spinbutton", { name: "勤続年数" }).click();
await page.getByRole("spinbutton", { name: "勤続年数" }).fill("10");
await page.getByRole("spinbutton", { name: "退職金" }).click();
await page.getByRole("spinbutton", { name: "退職金" }).fill("5000000");
await page.getByRole("button", { name: "所得税を計算する" }).click();
// Then
await expect(
page.getByText("エラーが発生しました。しばらくしてからもう一度お試しください。")
).toBeVisible();
});
});
WireMockの使い方は他にもあるがとりあえず実現したいことが達成できて、SvleteKit + Vitest だとテストしづらかったことが、Playwright + WireMock で出来たので一旦終了。
次にMockoonを触る
と思ったのだけど Documentを読む限り目的が実現できない気がする。
MSWの様にテストケースごとにレスポンスを変えたりできるか?
リクエストのパラメータやヘッダー、ボディの内容をもとにResponseは変えられそうだが、WireMockのような特定のテストケースの時だけ指定したレスポンスに変えるというのが出来るように読めない。
またデスクトップツールで定義ファイルを予め作成しておき、Mockoonを使うときにそのファイルを指定する方法なのでテストコード内で自由にリクエストやレスポンスを書き換えるのも難しそう
CIでPlaywright で フロントエンドアプリをテストするときに使いやすいか?
「テスト実行環境内のインメモリあるいはコンテナでモックサーバーを起動して使う」という視点であれば使いづらい印象。
また Mockoon には Serverless Packageがあり AWSなどのサービス上で起動することなどを想定した使い方になっている。※ Serverless Packageには Expressで起動する方法や CLIで起動する方法などがあるので Docker コンテナで立ち上げることはできる。できるが、事前に設定ファイルを用意したりするので手間は掛かる。
なんとなくだが、Mockoonは コンポーネントテスト・統合テスト での利用というよりも システム統合テストなどでテスト対象ではないシステムのモックやスタブのためのサービスという印象を持った。
個人的には WireMockを使えばやりたかったことが出来たのでその時点で満足してしまったこともある。
まとめ
タイトルに偽りがあるものの WireMockを使うことで SvelteKitで開発したフロントエンドアプリケーション向けにバックエンドAPIのモックを用意することができた