AQ Tech Blog

フロントエンド結合テストの導入事例(Playwright + MSW)

作成者: takahiro.yamakita|2026年03月16日

はじめに

フロントエンド・バックエンド・インフラなど、様々な領域で開発に携わっている山北です。

私が参画している案件では、「リリース後に不具合が見つかることが多い」「コードが複雑化してしまっている」「手動テストの負担が大きすぎる」といった課題を抱えていました。

そのような課題を解決するために、フロントエンドに Playwright + MSW を使った結合テストを導入し、画面のふるまいを担保する仕組み を作りました。

本記事では、実際に結合テストを導入した際の流れや、テストコードの構成例などを紹介します。 同じような課題を抱えている方や、フロントエンドの自動テストの導入に興味がある方の参考になれば幸いです。

また、最後には、今回紹介した構成を最小プロダクトで再現したサンプルリポジトリも公開していますので、ぜひ参考にしてみてください。

目次

01. 自動テストとは


概要

自動テストとは、その名の通り、手動で行っていたテストを自動的に実行できるようにしたものです。 マウスやキーボードでUI を操作したり、APIを叩いてレスポンスを確認したりといった、人手による確認作業は手動テストと呼ばれます。

一方で、これら一連の確認作業をプログラムとして記述し、自動的に実行できるようにしたものが自動テストです。


自動テストの粒度

自動テストには、主に以下のような粒度の棲み分けがあります。

粒度 単体(Unit)テスト 結合(Integration)テスト E2E(End-to-End)テスト
主な目的 ロジックの正しさを保証する 複数のコンポーネントの連携を保証する システム全体の動作をユーザー視点で保証する
対象範囲 単体のクラスやメソッド 複数のクラスやコンポーネント アプリケーション全体
特徴 - 実行速度が速く、対象範囲が狭い
- ロジックの分岐やエッジケースの検証に向いている
- 実行速度および検証範囲は Unit と E2E の中間
- コンポーネント間の連携やデータフローの検証に向いている
- 実行速度が遅く、対象範囲が広い
- ユーザー操作のシナリオ全体を検証するのに向いている

すべての粒度のテストを同じ優先度で考えるのではなく、導入目的やプロジェクトの状況に応じて、どの粒度のテストをどの範囲に、どの程度導入するかを判断することが重要になります。

02. 課題と解決方針

ここからは、私が関わっている案件において、自動テストを導入した事例を紹介します。


案件概要

まずは、導入対象となる案件の概要を簡単に説明します。

  • プロダクト形態: Web アプリケーション
  • 画面数: 約10画面
  • 技術スタック:
    • フロントエンド: Nuxt.js
    • バックエンド: Java(Spring Boot)
    • ソースコード管理: マルチレポ構成(フロントエンド・バックエンドで別リポジトリ)

抱えていた課題

次に、本案件で発生していた主な課題について説明します。

  • 小さな変更であってもバグが発生することが多く、リリース作業の負担が大きい
  • コードが複雑化しており、チーム全体でリファクタリングの必要性を感じているものの、手動テストの負担が大きく、安心して着手できない
  • 過去にE2Eテストを導入した経験があるが、テストの不安定さに悩まされ、継続的な運用に至らなかった

解決方針

これらの課題を踏まえ、以下の前提条件を満たす形で自動テストを導入する必要があると考えました。

  • 既存コードへの影響が最小限であること (単体テストを導入する場合、既存コードを「単体化」するための大規模なリファクタリングが必要となり、現実的ではない)
  • テストの安定性に優れていること (過去のE2Eテスト導入時の失敗を踏まえて)
  • リファクタリング時の安全網として機能すること (安心してリファクタリングを進められる状態を作るため)

そこで、以下のようなテストをフロントエンドに導入する方針としました。

  • ユーザー視点での画面操作をシミュレートし、操作結果を検証できるテスト
  • API レベルでモックを行い、テストの安定性を担保したテスト

実現にあたり、以下の技術・ツールを採用しました。

  • Playwright : ブラウザ操作の自動化およびテストを行うためのフレームワーク
  • MSW(Mock Service Worker): ブラウザ環境で API リクエストをキャッチし、モックレスポンスを返すためのライブラリ

前述した自動テストの粒度の分類に照らし合わせると、本テストは「ユーザー視点で画面のふるまいを検証する結合テスト」もしくは「APIモックを用いたE2Eテスト」と言えそうです。

本記事では、以降このテストを 「結合テスト」 と呼び、説明を進めていきます。

03. 導入の流れ

実際にフロントエンドへ結合テストを導入した際の流れについて説明します。


1. 画面の優先度付け

まずはじめに、結合テストを導入するにあたり、どの画面から優先的に対応するかを決定しました。

対象のプロジェクトでは、画面数が約10画面と多くはありません。しかし全ての画面を一度に着手するのは現実的に難しいと判断したため、優先度の高い画面から順に少しずつ対応していく方針としました。

優先度の決定にあたっては、「ユーザーにとっての重要度」と「コードの複雑さ」から総合的に判断し、優先度付けを行いました。


2. 仕様の書き起こし → テストコード化 → CI 上で実行

次に、1つの画面に対して、以下の流れでテストコードの実装を行いました。

  1. 仕様の書き起こし
  2. テストコードの実装
  3. CI上でテストを実行

各段階の進め方や具体例について、以下で順番に説明します。

2-1. 仕様の書き起こし

対象のプロジェクトでは、画面の仕様を明文化したドキュメントが存在しておらず、仕様が暗黙知化している状態でした。

そのため、テストコードの実装にあたっては、まずは画面の仕様を改めて洗い出し、テストケースとして書き起こすところから始めました。

この段階を踏まずにテストコード作成を行う方法も考えられますが、事前に仕様およびテストケースを見える化しておくことで、チーム全体で「何をテストするのか」を共通認識として持った上でテストコードの実装に進むことができ、テストコードの内容に対する認識齟齬が生まれにくくなると考えたため、このような流れとしました。

次に、実際に仕様を洗い出し、テストケースとして書き起こした例を紹介します。

テストケースの書き起こしにあたっては、以下の観点で整理しました。

  1. どのような前提条件のもとで、
  2. どのような操作を行った際に、
  3. どのようなふるまいが期待されるか

1. どのような前提条件のもとで

画面にアクセスした際の前提条件を整理しました。 整理した結果、以下のような観点で前提条件を洗い出すことができました。

例:

  • アクセスしたユーザーはログインしているか
  • アクセスしたユーザーはどのような権限か
  • など

2. どのような操作を行った際に

画面上での操作に関する観点を整理しました。

例:

  • 画面にアクセスした際
  • 画面上で特定の操作を行った際(クリックや入力など)

3. どのようなふるまいが期待されるか

前提条件や操作に対して、どのようなふるまいが期待されるかを整理しました。

例:

  • 画面遷移: どの画面に遷移するか
  • 画面表示: どのような要素が表示されるか
  • APIリクエスト: どのようなAPIリクエストが発生するか

具体的な方法としては、スプレッドシートを用いて、上記観点における期待されるふるまいを整理しました。
以下に、実際に作成したテストケースの例を示します。

「画面にアクセスした際のふるまい」のテストケース例

「画面上で特定の操作を行った際のふるまい」のテストケース例

2-2. テストコードの実装

ここからは、書き起こしたテストケースをもとにPlaywright+MSWで結合テストを実装していく流れを紹介します。

前述したように、今回実装したテストは、バックエンドや外部環境まで含めて「全部動くこと」を保証することではありません。 ブラウザ上で画面を操作しつつ、APIはMSWで安定させることで、UIのふるまいを現実的なコストで担保するのが狙いです。

以降では、同じ「検索ページ」を例に、次のコード例を示します。

  1. 初期表示
  2. ユーザー操作 → APIリクエストの検証

コードの全体像や、コードの実装にあたってのポイントについては、後述のセクションでまとめて紹介します。

例1: 初期表示

初期表示のテストコードの例を示します。 以下のコードは、検索ページにアクセスした際のふるまいを検証するテストコードの例です。

// tests/integration/pages/search.spec.js
import { test, expect } from '../index.js'

test.describe('検索ページ', () => {
	// globalSetup で保存した認証情報(cookie)を含む storageState.json を、テスト実行前に読み込む
	test.use({ storageState: 'tests/integration/storageState.json' })

	test('画面遷移ができる', async ({ page, searchPage }) => {
		// [Act]
		// 検索ページにアクセス
		await searchPage.goto()

		// [Assert]
		// 画面遷移が成功し、ヘッダーと検索結果欄が表示されている
		await searchPage.toBeOnPage()
		await expect(page.getByTestId('header')).toBeVisible()
		await expect(page.getByTestId('search_result')).toBeVisible()
	})
})
例2: ユーザー操作 → APIリクエストの検証

「画面上の操作」と「その結果として発行されるAPIリクエスト」をまとめて検証するテストコードの例を示します。 以下のコードは、検索ページでキーワードを入力して検索を実行した際に、API リクエストのパラメータに入力したキーワードが含まれていることを検証するテストコードの例です。

// tests/integration/pages/search.spec.js
import { test, expect } from '../index.js'

test.describe('検索ページ', () => {
	// globalSetup で保存した認証情報(cookie)を含む storageState.json を、テスト実行前に読み込む
	test.use({ storageState: 'tests/integration/storageState.json' })

	test('キーワード検索: 入力したキーワードが検索APIに渡される', async ({ page, searchPage }) => {
		// [Arrange]
		// APIパラメータ検証のため、対象リクエストを待機する
		const apiRequestPromise = waitForApiRequest(page, '/api/search', 'POST', 2)

		// [Act]
		// 検索ページにアクセスし、キーワードを入力して検索を実行
		await searchPage.goto()
		await searchPage.searchByKeyword('コンクリート')

		// [Assert]
		// API リクエストのパラメータに、入力したキーワードが含まれている
		await expect(await apiRequestPromise.postDataJSON()).toEqual(
			expect.objectContaining({
				keywords: 'コンクリート',
			})
		)
	})
})

// ブラウザ上で発生する API リクエストを待機するためのヘルパー
function waitForApiRequest(page, url, method, nth = 1) {
	let count = 0
	return new Promise((resolve) => {
		const handler = (request) => {
			if (request.url().includes(url) && request.method() === method) {
				count++
				if (count === nth) {
					page.off('request', handler)
					resolve(request)
				}
			}
		}
		page.on('request', handler)
	})
}

以上がテストコード例になります。 全体のアーキテクチャや、テストコードの実装にあたってのポイントについては、後述のセクションでまとめて紹介します。

2-3. CI 上でテストを実行

最後に、作成したテストコードを CI に組み込み、自動的にテストが実行されるようにしました。

今回はGitHub Actionsを利用し、プルリクエスト作成時に自動でテストが実行されるよう設定しています。 以下に、GitHub Actionsの設定例を示します。

# .github/workflows/integration-test.yml
name: IntegrationTesting

on:
	pull_request:
		branches:
			- develop

jobs:
	to_codecommit:
		runs-on: ubuntu-22.04
		steps:
			- name: Checkout code
				uses: actions/checkout@v4
				with:
					ref: ${{ github.head_ref }}

			- name: Setup Node.js
				uses: actions/setup-node@v4
				with:
					node-version: '20'

			- name: Install dependencies
				run: yarn install
      
			- name: Install playwright
				run: yarn playwright install

			- name: Run test
				run: yarn test:intg
  • yarn test:intg は、結合テストを実行するためのコマンドです。 このコマンドは、プロジェクトの package.json 内で、Playwright を利用して結合テストを実行するためのスクリプトとして定義しています。

04. 導入した構成の紹介

今回導入した結合テストの全体の構成を紹介します。 ディレクトリ構成、ファイル関係図、主要ファイルのコード例を順に紹介していきます。

注記

  • 記事の構成を最小プロダクトで再現したサンプルを記事末に用意しています。手元で動かしながら読みたい方は、先にそちらを参照してください。
  • この記事内のコード例は、そのまま流用して使っても問題ありません。ただし説明のために一部を省略・抜粋しているため、実際のコードとは異なる箇所があります。動作する一式はサンプルリポジトリを参照してください。

前提環境

本記事の構成・コード例は、以下の環境を前提としています。

  • Node.js 22.x
  • yarn 1.22.x
  • Nuxt.js 2.16.x
  • @playwright/test 1.45.x
  • msw 0.47.x
  • playwright-msw 2.x
  • start-server-and-test 2.x

注記

  • 本記事のコード例は、 msw 0.47.x を前提に記載しています。
  • 記事の公開時点では、msw 2.x が最新バージョンとなっており、最新バージョンでは、ハンドラ定義など一部の記法が異なっています(例: resthttp など)。
  • 実際に導入する際は、利用中のバージョンに合わせて公式ドキュメントを参照してください。
  • Mock Service Worker - API mocking library for browser and Node.js

ディレクトリ構成

主なファイルのディレクトリ構成を以下に示します。

├─ mocks/                     # MSW (Mock Service Worker) のモック定義
│  ├─ responses/***.json      # モックレスポンスデータ (JSON)
│  └─ handlers.js             # モックレスポンスのハンドラ
├─ package.json               # 依存関係やテスト実行用のスクリプトを定義
├─ pages/                     # Nuxt.js のページ群
├─ playwright.config.js       # Playwright の設定ファイル
├─ plugins/msw.client.js      # MSW のセットアップ
├─ tests/
│  ├─ integration/            # 結合テスト
│  │  ├─ pages/***.spec.js    # 各画面のテストコード
│  │  ├─ utils/               # テスト用のユーティリティ関数
│  │  └─ index.js             # Fixture (テスト実行用の部品を集約したオブジェクト)
│  ├─ e2e/                    # E2E テスト (今回は紹介しませんが、Playwright を用いて E2E テストも実装しています)
│  └─ playwright/             # Playwright 共通設定
│     ├─ pageObjects/         # Page Object Model (画面操作を集約したオブジェクト)
│     │  └─ pages/***.js      # 各画面の Page Object
│     └─ reports/             # Playwright のテストレポート
└─ .github/workflows/integration-test.yml # GitHub Actions の設定ファイル

ファイル関係図

各ファイルの関係性を図示すると以下のようになります。

大きく、実行設定、APIのモック定義、Page Object、Fixture、テストコードの5つの要素に分けることができます。

  • 実行設定: Playwrightの設定ファイルやGitHub Actionsの設定ファイルなど、テストの実行に関する設定ファイル
  • APIのモック定義: MSWを用いて、APIのモックレスポンスを定義するファイル
  • Page Object: 各画面の操作を集約したオブジェクト
  • Fixture: テスト実行用の部品を集約したオブジェクト
  • テストコード: 実際のテストを記述するファイル

主要ファイルのコード例

package.json

テストの実行に必要な依存関係や、テスト実行用のスクリプトを定義するファイルです。 以下に、今回の構成で必要な依存関係と、テスト実行用のスクリプトのコード例を示します。

{
	"scripts": {
		"dev:mock": "cross-env PORT=3001 ENABLE_API_MOCK=true nuxt",
		"test:intg": "start-server-and-test \"yarn dev:mock\" http://localhost:3001 \"yarn playwright test tests/integration/*\""
	},
	"devDependencies": {
		"@playwright/test": "^1.45.0",
		"cross-env": "^7.0.3",
		"msw": "^0.47.0",
		"playwright-msw": "^2.0.0",
		"start-server-and-test": "^2.0.0"
	}
}
dev:mock

MSWによるAPIのモックを有効にした状態で、Nuxt.jsの開発サーバーを起動するためのスクリプトです。 PORT=3001で起動し、結合テストの実行時の接続先を固定し、ENABLE_API_MOCK=trueでMSWを有効にするための環境変数を渡しています。 このENABLE_API_MOCKという環境変数は、Nuxt.jsの起動コード内で参照されており、MSWを有効にするかどうかを切り替えるために使用しています。

test:intg

dev:mockスクリプトでNuxt.jsの開発サーバーを起動し、サーバーがhttp://localhost:3001で起動したことを確認してから、Playwrightの結合テストを実行するためのスクリプトです。 start-server-and-testは、指定したサーバー起動コマンドの実行 → 指定URLへの疎通確認(起動待機)→ テストコマンドの実行 を順に行うnpmパッケージです。 テストが完了した後は、起動したサーバーも合わせて終了してくれるため、テストの実行とサーバーの起動を一連の流れで完結させることができます。

このスクリプトをローカル環境やCI環境で実行することで、結合テストを実行することができます。

playwright.config.js

Playwrightの設定ファイルです。 このファイルでは、結合テストの実行環境や、テストの実行方法、レポート出力方法を設定しています。

import { devices } from '@playwright/test'

const config = {
	testDir: 'tests/integration',
	globalSetup: './tests/integration/global-setup.js',
	outputDir: './tests/integration/report',
	reporter: 'html',
	use: {
		baseURL: 'http://localhost:3000',
		viewport: { width: 1280, height: 720 },
		storageState: 'tests/integration/storageState.json',
		trace: 'on-first-retry',
		ignoreHTTPSErrors: true,
		video: 'on',
		launchOptions: {
			slowMo: 0,
		},
	},
	projects: [
		{
			name: 'chromium',
			use: { ...devices['Desktop Chrome'] },
		},
	],
}

export default config

設定内容の詳細については、以下のドキュメントを参照してください。
Configuration | Playwright

plugins/msw.client.js

MSWをブラウザ上で動かすためのセットアップコードです。 このコードは、Nuxt.js のプラグインとして定義されておりNuxt.jsの起動時に実行されブラウザ上のService WorkerにMSW のワーカーを登録する役割を果たします。

これにより、ブラウザ上で発生するAPIリクエストをMSWがネットワーク層で傍受し、モックレスポンスを返すことができるようになります。

import { setupWorker } from 'msw'
import { handlers } from '@/mocks/handlers'

export default async () => {
	// development 環境かつ ENABLE_API_MOCK 環境変数が true の場合に MSW を起動する
	// テスト環境では msw は playwright-msw 側で起動する必要があるため, ここでは起動しないようにしている
	if (process.env.NODE_ENV === 'development' && process.env.ENABLE_API_MOCK) {
		try {
			const worker = setupWorker(...handlers)
			await worker.start({
				onUnhandledRequest: 'bypass', // MSW に handle されなかったリクエストはそのまま通す
			})

			console.log('[MSW] Mock Service Worker started.')
		} catch (error) {
			console.error('[MSW] Failed to start MSW worker\n', error)
		}
	}
}

mocks/handlers.js

MSWのモックハンドラを定義するファイルです。 このファイルでは、APIのエンドポイントごとに、どのようなリクエストを受け取った際に、どのようなレスポンスを返すかを定義しています。 ここで定義したハンドラは、テストコード内でMSWのワーカーを起動する際にブラウザ上のService Workerに登録され、テストコード内で発生するAPIリクエストを傍受して、モックレスポンスを返す役割を果たします。

import { rest } from 'msw'
import searchResponse from './responses/searchResponse.json'

const API_BASE_URL = 'https://localhost:8080'

export const handlers = [
	rest.post(`${API_BASE_URL}/api/login`, (req, res, ctx) => {
		return res(ctx.status(200), ctx.json({}))
	}),

	rest.post(`${API_BASE_URL}/api/search`, async (req, res, ctx) => {
		return res(ctx.status(200), ctx.json(searchResponse))
	}),
]

tests/playwright/pageObjects/pages/searchPage.js

今回は、Page Object Modelパターンを採用し、テスト対象の各画面をクラス化しています。


// テスト対象の画面


// テストコード
const searchButton = page.getByTestId('search_button')

参考までに、Playwright が提供する Locator の API については、以下のドキュメントを参照してください。
Locator | Playwright

このように、テストコード内で画面要素を操作対象とするためには、都度 Locator を定義し、その画面にどんな要素があり、どんな手段でアクセスできるかを定義する必要があります。 この定義をテストコードから分離し、画面ごとにクラス化してまとめるのが Page Object Model パターンです。 このパターンを採用することにより、テストコードには「何をテストするか」だけが記述され、可読性が向上します。また、画面要素の定義は画面ごとのオブジェクト(Page Object)に集約されるため、画面要素の変更があった際も、変更箇所を Page Object に限定することができ、保守性も向上します。

以下に、検索ページの Page Object のコード例を示します。

import { expect } from '@playwright/test'

class SearchPage {
	constructor(page) {
		this.page = page

		this.url = '/search'

		this.searchResult = page.getByTestId('search_result')

		this.searchButton = page.getByTestId('search_button')

		this.searchkeywordForm = page
			.getByTestId('search_keyword_form')
			.locator('input')
	}

	/**
	 * 検索ページにアクセス
	 */
	async goto() {
		await this.page.goto(this.url)
	}

	/**
	 * キーワード検索を実行
	 * @param {string} keyword - 検索キーワード
	 */
	async searchByKeyword(keyword) {
		await this.searchkeywordForm.fill(keyword)
		await this.searchButton.click()
	}

	/**
	 * 画面遷移が成功していることを検証
	 */
	async toBeOnPage() {
		await expect(this.page).toHaveURL(this.url)
	}
}

export { SearchPage }

tests/integration/index.js

MSWのワーカーの起動や、Page Objectのインスタンス化など、テストコードの実行に必要な部品を集約し、テストの実行環境を整えるためのファイルです。

Playwrightでは、テストを記述する際に、testオブジェクトを用いてテストコードを記述しますが、このtestオブジェクトを拡張する手段として、Fixtureという仕組みが提供されています。

Fixtureについては、以下のドキュメントを参照してください。
Fixtures | Playwright

以下に、今回作成したFixtureのコード例を示します。

import { test as base } from '@playwright/test'
import { rest } from 'msw'
import { createWorkerFixture } from 'playwright-msw'
import { handlers } from '../../mocks/handlers.js'
import { SearchPage } from '../playwright/pageObjects/pages/search/index.js'

export const test = base.extend({
	worker: createWorkerFixture(handlers),
	rest,
	searchPage: async ({ page }, use) => {
		const searchPage = new SearchPage(page)
		await use(searchPage)
	}
})

export { expect } from '@playwright/test'

上記のコードでは、Playwrightのtestオブジェクトを拡張し、以下の Fixture を定義しています。

  • worker: playwright-mswcreateWorkerFixtureを用いて、MSWのワーカーを起動するためのFixture
  • rest: MSWのRESTハンドラを定義するためのFixture
  • searchPage: 検索ページのPage Objectをインスタンス化するためのFixture

ここでexportした test オブジェクトを使用することで、テストコード内でPage Objectを使用することができます。 また、playwright-mswのワーカーをテストコードから操作できるようになり、テストコード内でAPIのモックレスポンスを動的に変更することも可能になります。

playwright-mswについては、以下のドキュメントを参照してください。
GitHub - mswjs/playwright: Mock Service Worker binding for Playwright. · GitHub

05. 導入の効果

最後に、結合テストを導入して得られた効果についてまとめます。


導入の効果

1. リファクタリングや仕様変更の安心感が増した

結合テストを入れて一番大きかったのは、「触るのが怖い」という心理的負担が確実に減ったことです。

結合テストで 「この画面で、ユーザーがこう操作すると、こう表示される」 という最低限の振る舞いが担保されるようになったことにより

  • 変更の影響範囲を見積もりやすくなる
  • リファクタリングがやりやすくなる
  • 仕様変更が入っても「どこを更新すべきか」が追いやすくなる

といった形で、安心して改善に踏み込めるようになりました。

2. バグの発見が早くなった

テストがCI上で回るようになると、バグの発見が「リリース後」や「レビュー後の手動確認」から、プルリクエストの時点に前倒しされます。

結果として、

  • バグが混入したまま次工程に進みにくい
  • 修正が必要になっても、差分が小さいうちに直せる
  • 手戻りが減るので、開発のテンポが落ちにくい

という効果がありました。

今回導入した結合テストではUIの見た目からどんなAPIリクエストが飛ぶかまでをまとめて検証できるため、 画面遷移や検索条件のような、壊れると機能の価値に直結する部分のバグを早く見つけやすくなったと感じています。

06. まとめ

今回は、結合テストの導入の経緯や、実際に導入した構成、導入して得られた効果について紹介しました。

自動テストの導入により、リファクタリングや仕様変更の安心感が増し、バグの発見も早くなったと感じています。

また、自動テストがない状態の案件では特に、結合テストはE2Eやユニットテストよりも導入のハードルが低く、現実的な選択肢になると考えています。

今回紹介した内容が、自動テストおよび結合テストの導入を検討されている方の参考になれば幸いです。

Appendix : サンプルリポジトリ

今回の記事で紹介した構成(Playwright + MSW を用いたフロントエンドの結合テスト)を、最小プロダクトで再現したサンプルリポジトリを公開しています。
「まず動くコードを確認したい」「実際のコードを見ながら記事を読みたい」という方は、以下を参照してください(実行手順はREADMEにまとめています)。

GitHub - tkworks1214/frontend-integration-testing-example · GitHub