Closed14

WireMock を Playwright(JS)で使う

kazokmrkazokmr

目的

Next.js や SvelteKit のような専用サーバーを配置するフロントエンドアプリの統合テストのために、バックエンドAPIのモックサーバーを用意したい。
調べたところ WireMock と Mockoon が良さそうだったのでそれぞれを利用してどんなことができるかをまとめる。

調査ポイント

  • MSWの様にテストケースごとにレスポンスを変えたりできるか?
    • レスポンスの値による境界分割や同値分割、あるいはデシジョンテーブルのようなテストに対応できるか?
    • 同じリクエストでも 成功時とエラー時のハンドリングをテストしたいため
  • CIでPlaywright で フロントエンドアプリをテストするときに使いやすいか?
    • 今回はGitHub Actionsで実行できることを想定
    • ローカル環境でテストできることも考えておく
  • REST API (OpenAPI)で確認する
  • 今回は以前作ったSvelteKitで調査する

製品のHP

WireMock

https://wiremock.org/

Mockoon

https://mockoon.com/

kazokmrkazokmr

最初に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

https://wiremock.org/docs/solutions/nodejs/

https://github.com/HBOCodeLabs/wiremock-captain/tree/main

kazokmrkazokmr

次のようなテストPlayWrightを使って実行する。ちなみに サンプルは Jest本 を元にSvelteKit + Vitest で作成したもの

test.ts
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";

で、テストを実行すると、成功した!

kazokmrkazokmr

次にエラーハンドリングをテストしてみる。WireMockでResponseが変わることを確認したいので、リクエストは先ほどと同じ内容にし、結果だけが異なるように書く。

test.ts
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サーバーからのエラーを処理していなかったので、付け足した。

+page.server.ts
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;

すると、エラーの方も成功した。

kazokmrkazokmr

むしろ、これまでのテストコードではAPIからのエラーハンドリングの検証ができていなかったので、この部分のテストコードを書くためにも APIサーバーのモック化は大事だと改めて思った。(API側で意図的にエラーを出すのは準備が結構大変だと思うし)

kazokmrkazokmr

WireContainerをTestcontainersで起動しテストコードを実行することで完結させたい。

https://node.testcontainers.org/supported-container-runtimes/

npm install --save-dev testcontainers

テストコードで以下のようにTestcontaiersでwiremockを立ち上げる

test.ts
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つの方法を検討する。

  1. アプリ側でコンテナのランダムポートでアクセスできる様にする
  2. Testcontainers側のホストポートを指定できる様にする
kazokmrkazokmr

アプリ側でTestcontainersで生成したコンテナのポートにアクセスするためにはまず、WireMockコンテナの起動 -> SvelteKitサーバーのビルドと起動 としなければならなさそう。理由は SvelteKitアプリをビルドしたときに環境変数を読み込むことになるため。
ただ、Playwrightを使っていると、Playwrightを起動したときに SvelteKitアプリのビルドとサーバー起動を行ってからテストを実行するので、どうしてもコンテナの起動の方が後になってしまう?

SvelteKitの $env/dynamic/privateに注入できないかも考えているけど、Playwrightで起動したサーバーにどのようにアクセスして環境変数を定義すれば良いか不明。

kazokmrkazokmr

TestcontainersでWireMockを起動してテストする方法は諦めて、CIでコンテナを用意することにする。
ローカルでテストする時には事前にWireMockを起動する。

kazokmrkazokmr

docker-compose からTestcontainersを起動する様にしたらポートを指定してテストすることができたっぽい

./tests/compose.yml
services:
  wire-mock:
    image: wiremock/wiremock
    container_name: wiremock
    environment:
      TZ: Asia/Tokyo
    ports:
      - 3000:8080
test.ts
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();
  });
});
kazokmrkazokmr

WireMockの使い方は他にもあるがとりあえず実現したいことが達成できて、SvleteKit + Vitest だとテストしづらかったことが、Playwright + WireMock で出来たので一旦終了。

kazokmrkazokmr

次にMockoonを触る

と思ったのだけど Documentを読む限り目的が実現できない気がする。

MSWの様にテストケースごとにレスポンスを変えたりできるか?

リクエストのパラメータやヘッダー、ボディの内容をもとにResponseは変えられそうだが、WireMockのような特定のテストケースの時だけ指定したレスポンスに変えるというのが出来るように読めない。

またデスクトップツールで定義ファイルを予め作成しておき、Mockoonを使うときにそのファイルを指定する方法なのでテストコード内で自由にリクエストやレスポンスを書き換えるのも難しそう

CIでPlaywright で フロントエンドアプリをテストするときに使いやすいか?

「テスト実行環境内のインメモリあるいはコンテナでモックサーバーを起動して使う」という視点であれば使いづらい印象。
また Mockoon には Serverless Packageがあり AWSなどのサービス上で起動することなどを想定した使い方になっている。※ Serverless Packageには Expressで起動する方法や CLIで起動する方法などがあるので Docker  コンテナで立ち上げることはできる。できるが、事前に設定ファイルを用意したりするので手間は掛かる。

なんとなくだが、Mockoonは コンポーネントテスト・統合テスト での利用というよりも システム統合テストなどでテスト対象ではないシステムのモックやスタブのためのサービスという印象を持った。

kazokmrkazokmr

個人的には WireMockを使えばやりたかったことが出来たのでその時点で満足してしまったこともある。

kazokmrkazokmr

まとめ

タイトルに偽りがあるものの WireMockを使うことで SvelteKitで開発したフロントエンドアプリケーション向けにバックエンドAPIのモックを用意することができた

このスクラップは2023/06/28にクローズされました