「テストは書いているのに、リファクタリングのたびに壊れる」「結合テストと E2E テストの使い分けがよくわからない」——実務経験を積んだエンジニアほど、こうした設計レベルの悩みに直面します。
本記事では、現在のフロントエンド開発でスタンダードになりつつある Vitest と Playwright を軸に、テスト戦略の全体像から実装の具体的なポイントまでを体系的に解説します。すでにテストを書いている方が「より壊れにくく、意味のあるテスト設計」へ移行するための実践ガイドとして活用してください。
技術的背景:なぜ今 Vitest と Playwright なのか
かつてフロントエンドのユニットテストは Jest が長年にわたって主流でした。しかし Vite ベースのプロジェクトが普及するにつれ、Jest の内部的な Babel 依存が問題になってきました。Vite は ESM をネイティブに扱う高速なビルドが売りですが、Jest はデフォルトで CommonJS を前提とした変換処理を挟むため、設定の複雑化やトランスパイルの二重管理が生じやすいのです。
Vitest はこの問題を根本から解消します。Vite の設定とトランスパイラをそのまま共有するため、vite.config.ts のエイリアスやプラグインがテスト環境でもそのまま有効になります。import.meta.env を使った環境変数の扱いや、カスタムリゾルバーを設定している場合も、追加対応なしにテストで動作します。
E2E テストでは Playwright がデファクトスタンダードとして定着しつつあります。Cypress と比較した際の主な優位点は次の3点です。まず、Chromium・Firefox・WebKit を単一の API で操作できるクロスブラウザ対応。次に、テストをワーカープロセスで並列実行できる設計による CI での高速化。そして、失敗したテストの操作ログやネットワーク通信を記録した Trace Viewer による強力なデバッグ体験です。
テスト戦略の指針:テスティングトロフィー
モダンなテスト設計において最も重要な考え方が、Kent C. Dodds 氏が提唱する テスティングトロフィー(Testing Trophy) です。従来の「テストピラミッド」がユニットテストを最重視するのに対し、テスティングトロフィーは 結合テスト(Integration)を中心に据える 点が特徴です。
-
Static(静的解析)
ESLint による構文チェックや TypeScript による型検査。実行コストがほぼゼロで最も広範なカバレッジを得られるため、まず徹底するべき層です。strict: trueの TypeScript 設定と、適切な ESLint ルールセットの整備がここでの投資対効果を最大化します。 -
Unit(単体テスト)
外部依存のない純粋な関数やユーティリティに絞って適用します。重要なのは「何をユニットテストするべきか」の判断で、コンポーネント内部のロジックを直接テストしようとすると実装詳細への結合が生まれます。カスタムフックや複雑な計算ロジックの切り出しを先に行い、それを対象にする設計が効果的です。 -
Integration(結合テスト)
テスティングトロフィーにおける中核です。Testing Library を使い、DOM の内部構造ではなく「アクセシビリティ属性(Role)や表示テキスト」に基づいて要素を取得することで、コンポーネントの内部実装を変えてもテストが壊れないリファクタリング耐性を実現します。MSW によるネットワークモックと組み合わせることで、API 連携を含むコンポーネントの動作も現実に近い形で検証できます。 -
E2E(エンドツーエンドテスト)
Playwright を使用し、実際のブラウザ上でユーザーが辿る一連のフローを検証します。実行コストが高いため、ログイン・決済・フォーム送信といったビジネス上クリティカルなシナリオに絞って適用するのが鉄則です。全テストを E2E に寄せると CI が重くなり、フィードバックループが損なわれます。
エンジニアVitest への移行で特に助かったのは、Vite のパスエイリアスがそのままテストでも動いた点です。Jest 時代は moduleNameMapper でエイリアスを別途定義する必要があり、設定が二重管理になっていました。
デザイナーPlaywright のビジュアルリグレッションテストは、スクリーンショットをコミット時にベースラインとして保存しておき、差分が出たら CI で検知する仕組みです。意図しない CSS の崩れを自動で検出できるので、デザイン品質の担保にも役立っています。
実装:各レイヤーの具体的な書き方
1. Vitest の環境構築
ブラウザ API をエミュレートする場合は environment を設定します。jsdom が一般的ですが、happy-dom の方が高速なケースも多く、まず happy-dom を試してみる価値があります。なお、globals: true を設定すると describe や it を import なしで使えますが、型補完のために tsconfig.json の types に "vitest/globals" を追加することも忘れずに。
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'happy-dom', // jsdom より高速な場合が多い
setupFiles: './src/test/setup.ts',
},
});
// src/test/setup.ts
import '@testing-library/jest-dom'; // toBeInTheDocument などの matchers を拡張
2. MSW による API モック
MSW は Service Worker を使ってネットワーク層をインターセプトするライブラリです。テスト環境(Node.js)では setupServer、ブラウザ環境では setupWorker を使い分けます。重要なのは ハンドラーをテストケースごとに上書きできる 点で、正常系・エラー系・ローディング中の状態を柔軟に切り替えてテストできます。
// src/test/server.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([{ id: 1, name: 'Alice' }]);
}),
];
export const server = setupServer(...handlers);
// src/test/setup.ts に追記
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers()); // テストごとにハンドラーをリセット
afterAll(() => server.close());
エラーケースのテストでは、server.use() で特定のテスト内のみハンドラーを上書きします。
it('API エラー時にエラーメッセージが表示されること', async () => {
// このテスト内だけ 500 を返すよう上書き
server.use(
http.get('/api/users', () => {
return new HttpResponse(null, { status: 500 });
})
);
render( );
expect(await screen.findByText(/エラーが発生しました/i)).toBeInTheDocument();
});
3. Testing Library による結合テスト
user-event は v14 以降、setup() でインスタンス化してから使うのが推奨パターンです。これにより、キーボード操作やポインターイベントがより実際のブラウザに近い形でシミュレートされます。また、非同期でデータを表示するコンポーネントには findBy(内部的に waitFor でポーリング)を使うのが適切です。
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { Counter } from './Counter';
describe('Counter Component', () => {
it('ボタンをクリックするとカウントが増加すること', async () => {
const user = userEvent.setup();
render( );
// 初期状態の確認(Role ベースの取得がベストプラクティス)
expect(screen.getByText(/count is 0/i)).toBeInTheDocument();
// userEvent.setup() 経由でシミュレーションすることで
// focus・blur・pointer イベントも含めてリアルに再現される
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText(/count is 1/i)).toBeInTheDocument();
});
});
4. Playwright による E2E テスト
Playwright では page.getByRole() や page.getByLabel() など、Testing Library に近い ユーザー視点のロケーター が推奨されています。page.locator('.submit-button') のような CSS セレクターへの依存は、クラス名の変更でテストが壊れる原因になるため避けましょう。また、await expect(locator).toBeVisible() のようなアサーションは内部的に自動リトライするため、明示的な waitFor は多くの場合不要です。
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test('正しい認証情報でログインできること', async ({ page }) => {
await page.goto('/login');
// getByLabel でフォーム要素を取得(アクセシビリティに基づいた取得)
await page.getByLabel('メールアドレス').fill('user@example.com');
await page.getByLabel('パスワード').fill('password123');
await page.getByRole('button', { name: 'ログイン' }).click();
// URL の遷移を確認
await expect(page).toHaveURL('/dashboard');
// ダッシュボードの要素が表示されていることを確認
await expect(page.getByRole('heading', { name: 'ダッシュボード' })).toBeVisible();
});
よくある落とし穴と対策
中級者がテスト設計でつまずきやすいポイントを3点挙げます。
① 実装の詳細をテストしてしまう
コンポーネントの state や props を直接検査したり、内部メソッドを呼び出してテストするのは、実装詳細への結合です。Testing Library の思想に従い、「ユーザーが画面で見ていること・操作できること」だけを検証する設計に切り替えることで、リファクタリング耐性が大幅に向上します。
② act 警告を闇雲に抑制する
act() 警告は「状態更新が完了していない可能性がある」というシグナルです。// @ts-ignore や act() でラップして警告を消すだけでは根本解決になりません。findBy や waitFor を使って状態の更新を適切に待つ設計に直すのが正しいアプローチです。
③ E2E テストに頼りすぎる
カバレッジを E2E で稼ごうとすると、CI の実行時間が長くなりフィードバックが遅れます。E2E はクリティカルパスのみに絞り、コンポーネント単位の異常系や境界値は結合テストで担うという役割分担を明確にすることが重要です。
まとめ
- Vitest は Vite のエコシステムと設定を共有でき、エイリアスや環境変数の二重管理を解消できる。
- テスティングトロフィー に基づき、結合テストを中心に据えることでリファクタリング耐性の高いテスト設計が実現する。
- MSW を使えばテストケースごとに API レスポンスを柔軟に切り替えられ、正常系・エラー系を網羅したコンポーネントテストが書ける。
- Playwright はクリティカルなユーザーフローに絞って適用し、Role ベースのロケーターと自動リトライ付きアサーションを活用する。
- 「実装の詳細をテストしない」という原則を守ることが、長期的なメンテナンスコストの削減に直結する。