Timee Product Team Blog

タイミー開発者ブログ

PlaywrightとMSWを組み合わせる

この記事はTimee Product Advent Calendar 2024 シリーズ2の21日目の記事です。

はじめに

タイミーでフロントエンドエンジニアをしている大川です。
PlaywrightでのUI自動テストを運用してきた中で学んだ、PlaywrightとMock Service Worker(以降、MSW)を組み合わせたAPIのモック方法を紹介します。

Playwrightのテスト中にモックレスポンスを定義する

バックエンドと連携しているシステム全体をテストすることで正常に動作しているか確認していくことは大切ですが、普段のUI開発やUIで利用しているライブラリアップデート後の動作確認時などはAPIリクエストをモックしてPlaywrightのテストを実行するのも有効な場合があると思います。

APIリクエストをモックするためのライブラリにMSWを利用している場合は、開発中に定義したモックハンドラーを再利用してテスト実装を効率化できます。

PlaywrightとMSWを組み合わせる場合には、テストシナリオ内でレスポンスを定義するための工夫が必要です。以下にいくつかの方法をまとめました。

テスト対象のUIでMSWが動作している場合
テスト対象のUIがバックエンドと結合している場合

自前のフィクスチャを定義する

playwright-mswでは、最後に追加されたハンドラーが最初に実行されるよう、すべてのハンドラー定義順序をリバースしている箇所があります。
参考: ハンドラーをリバースしているコード

MSWにも実行順序のルールがあり、それを考慮してハンドラーを用意していると意図しないレスポンスになる場合がありました。
参考: Request handler | Execution order

playwright-mswを利用せず、MSWのハンドラーをベースにモック定義するためにgetRespose 関数が利用できます。
参考: getResponse

以下のコードは getResponse を利用してAPIリクエストをモックしているフィクスチャと、フィクスチャの利用例としてのテストになります。

(フィクスチャの実装例)

import { test as base, expect } from '@playwright/test';
import { http, HttpResponse, RequestHandler, getResponse } from 'msw';

const apiEndpoint = process.env.API_ENDPOINT;

// NOTE: MSWのモックハンドラー定義の例(実際は別ファイルで管理されている想定)
const initialHandlers = [
  http.get(`${apiEndpoint}/user/:userId`, () => {
    return HttpResponse.json({ name: 'John' });
  }),
];

// NOTE: テストコード中でもモック定義するためのビルダーを定義
class MockBuilder {
  private handlers: RequestHandler[] = initialHandlers;
  constructor(...overrides: RequestHandler[]) {
    this.handlers = [...overrides, ...initialHandlers];
  }

  use(...additionalHandlers: RequestHandler[]) {
    this.handlers = [...additionalHandlers, ...this.handlers];
  }

  reset() {
    this.handlers = initialHandlers;
  }

  getHandlers() {
    return this.handlers;
  }
}

const test = base.extend<{ mockBuilder: MockBuilder }>({
  mockBuilder: [
    async ({ page }, use) => {
      const builder = new MockBuilder();
      await page.route(`${apiEndpoint}/**`, async (route) => {
        const actualRequest = route.request();

        // NOTE: MSWとPlaywrightでRequest型が違うので再定義している
        const request = new Request(actualRequest.url(), {
          method: actualRequest.method(),
          headers: actualRequest.headers(),
          body: actualRequest.postData(),
        });

        const response = await getResponse(builder.getHandlers(), request);
        if (!response) {
          await route.fulfill({
            status: 404,
          });
          return;
        }

        const json = await response.json();
        await route.fulfill({ json, status: response.status });
      });

      await use(builder);
      // NOTE: テストコード中に上書きしたハンドラーをリセットする
      builder.reset();
    },
    // NOTE: auto: trueでmockBuilderをテストコード中に呼び出さない場合もセットアップしておく
    { auto: true },
  ]
});

export { expect, test };

(テストの実装例)

test('ユーザーIDを編集できる', async ({ page, mockBuilder }) => {
  // NOTE: 定義済みのユーザー情報取得に加えてユーザーID編集APIをモックする
  mockBuilder.use(
    http.patch(`${apiEndpoint}/user/:userId`, () => {
      return HttpResponse.json({ result: 'OK' });
    }),
  );

  // NOTE: テスト開始
  const newUserId = 'new_user_id';
  await expect(page).toHaveURL('/user/123/edit');
  const input = page.getByRole('textbox', { name: 'ユーザーID' });
  await input.fill(newUserId);
  const button = page.getByRole('button', { name: '保存' });
  await button.click();
  await expect(page).toHaveURL('/user/123');
  const userId = page.getByText(newUserId);
  await expect(userId).toBeVisible();
});

さいごに

今回はPlaywrightとMSWを組み合わせてAPIレスポンスをモックする方法を紹介しました。
PlaywrightもMSWもAPIが充実しており、いろんなテストシナリオに対応できて便利です。

弊社ではPlaywrightでのテスト以外にも静的解析、コンポーネントやフックの単体テスト、ビジュアルリグレッションテストなどを取り入れています。
自動テストを整備することで機能開発とその動作確認、ライブラリアップデートなどの運用をバランスよく実施していける仕組みをつくっていき、お客さまによりよいUI・UXをより早く提供していくことにつなげていければと考えています。