モダンなフロントエンドテストの最適解:VitestとPlaywrightで築く信頼性

目次

「テストは書いているのに、リファクタリングのたびに壊れる」「結合テストと E2E テストの使い分けがよくわからない」——実務経験を積んだエンジニアほど、こうした設計レベルの悩みに直面します。

本記事では、現在のフロントエンド開発でスタンダードになりつつある VitestPlaywright を軸に、テスト戦略の全体像から実装の具体的なポイントまでを体系的に解説します。すでにテストを書いている方が「より壊れにくく、意味のあるテスト設計」へ移行するための実践ガイドとして活用してください。

技術的背景:なぜ今 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)を中心に据える 点が特徴です。

  1. Static(静的解析)
    ESLint による構文チェックや TypeScript による型検査。実行コストがほぼゼロで最も広範なカバレッジを得られるため、まず徹底するべき層です。strict: true の TypeScript 設定と、適切な ESLint ルールセットの整備がここでの投資対効果を最大化します。
  2. Unit(単体テスト)
    外部依存のない純粋な関数やユーティリティに絞って適用します。重要なのは「何をユニットテストするべきか」の判断で、コンポーネント内部のロジックを直接テストしようとすると実装詳細への結合が生まれます。カスタムフックや複雑な計算ロジックの切り出しを先に行い、それを対象にする設計が効果的です。
  3. Integration(結合テスト)
    テスティングトロフィーにおける中核です。Testing Library を使い、DOM の内部構造ではなく「アクセシビリティ属性(Role)や表示テキスト」に基づいて要素を取得することで、コンポーネントの内部実装を変えてもテストが壊れないリファクタリング耐性を実現します。MSW によるネットワークモックと組み合わせることで、API 連携を含むコンポーネントの動作も現実に近い形で検証できます。
  4. E2E(エンドツーエンドテスト)
    Playwright を使用し、実際のブラウザ上でユーザーが辿る一連のフローを検証します。実行コストが高いため、ログイン・決済・フォーム送信といったビジネス上クリティカルなシナリオに絞って適用するのが鉄則です。全テストを E2E に寄せると CI が重くなり、フィードバックループが損なわれます。
エンジニア

Vitest への移行で特に助かったのは、Vite のパスエイリアスがそのままテストでも動いた点です。Jest 時代は moduleNameMapper でエイリアスを別途定義する必要があり、設定が二重管理になっていました。

デザイナー

Playwright のビジュアルリグレッションテストは、スクリーンショットをコミット時にベースラインとして保存しておき、差分が出たら CI で検知する仕組みです。意図しない CSS の崩れを自動で検出できるので、デザイン品質の担保にも役立っています。

実装:各レイヤーの具体的な書き方

1. Vitest の環境構築

ブラウザ API をエミュレートする場合は environment を設定します。jsdom が一般的ですが、happy-dom の方が高速なケースも多く、まず happy-dom を試してみる価値があります。なお、globals: true を設定すると describeit を import なしで使えますが、型補完のために tsconfig.jsontypes"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-ignoreact() でラップして警告を消すだけでは根本解決になりません。findBywaitFor を使って状態の更新を適切に待つ設計に直すのが正しいアプローチです。

③ E2E テストに頼りすぎる
カバレッジを E2E で稼ごうとすると、CI の実行時間が長くなりフィードバックが遅れます。E2E はクリティカルパスのみに絞り、コンポーネント単位の異常系や境界値は結合テストで担うという役割分担を明確にすることが重要です。

Storybook との連携:コンポーネント開発とテストの統合

テスト戦略を語る上で、Storybook との連携は欠かせないピースです。Storybook はコンポーネントを独立した環境でカタログ化するツールとして知られていますが、近年はテストプラットフォームとしての側面も強くなっています。特に Story をテストのデータソースとして再利用する アーキテクチャは、テストコードの重複を大幅に削減できる点で注目されています。

1. Story ファイルの基本構成(CSF3)

Storybook v7 以降で標準となった CSF3(Component Story Format 3) では、Story をオブジェクトとして定義します。args に渡したプロパティはそのままテストでも使い回せるため、「Story を書く = テストのセットアップが完成する」という状態に近づきます。

// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

const meta: Meta = {
  title: 'Components/Button',
  component: Button,
  // Storybook 上でのデフォルト args
  args: {
    label: 'クリック',
    disabled: false,
  },
};

export default meta;
type Story = StoryObj;

export const Primary: Story = {
  args: {
    variant: 'primary',
    label: '送信する',
  },
};

export const Disabled: Story = {
  args: {
    disabled: true,
    label: '送信できません',
  },
};

2. Story を Vitest のテストで再利用する

@storybook/reactcomposeStories を使うと、Story ファイルで定義した args やデコレーターを Vitest のテストに丸ごと持ち込めます。これにより、Story とテストでコンポーネントの状態を二重定義する問題を解消できます。

// Button.test.tsx
import { render, screen } from '@testing-library/react';
import { composeStories } from '@storybook/react';
import * as stories from './Button.stories';

// Story ファイルから全 Story をコンポーネントとして取得
const { Primary, Disabled } = composeStories(stories);

describe('Button', () => {
  it('Primary Story が正しくレンダリングされること', () => {
    render();
    expect(screen.getByRole('button', { name: '送信する' })).toBeInTheDocument();
  });

  it('Disabled Story でボタンが無効化されていること', () => {
    render();
    expect(screen.getByRole('button')).toBeDisabled();
  });
});

3. Storybook の play 関数でインタラクションテストを書く

CSF3 では、Story に play 関数を定義することで、Storybook の UI 上でインタラクションを自動実行できます。この play 関数は composeStories 経由で Vitest からも実行できるため、Storybook でのビジュアル確認と Vitest での自動テストを同一コードで実現できます。

// LoginForm.stories.tsx
import { within, userEvent, expect } from '@storybook/test';
import type { Meta, StoryObj } from '@storybook/react';
import { LoginForm } from './LoginForm';

const meta: Meta = {
  title: 'Components/LoginForm',
  component: LoginForm,
};

export default meta;

export const SubmitWithValidInput: StoryObj = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // ユーザー操作を Storybook / Vitest 双方で実行
    await userEvent.type(canvas.getByLabelText('メール'), 'user@example.com');
    await userEvent.type(canvas.getByLabelText('パスワード'), 'password123');
    await userEvent.click(canvas.getByRole('button', { name: 'ログイン' }));

    // アサーションも同様に共有
    await expect(canvas.getByText('ログイン成功')).toBeInTheDocument();
  },
};
// LoginForm.test.tsx
import { render } from '@testing-library/react';
import { composeStories } from '@storybook/react';
import * as stories from './LoginForm.stories';

const { SubmitWithValidInput } = composeStories(stories);

it('正しい入力でログインフォームが送信できること', async () => {
  const { container } = render();
  // play 関数をそのまま Vitest で実行
  await SubmitWithValidInput.play!({ canvasElement: container });
});

4. Storybook × MSW:API モックをカタログ上でも共有する

MSW は Storybook との連携も公式にサポートしており、msw-storybook-addon を導入することで、テストで使っているハンドラーを Storybook のデコレーターとしてそのまま流用できます。API 通信を含むコンポーネントを、バックエンドなしで Storybook 上で動かせるため、デザイナーやレビュアーとの協働にも役立ちます。

// .storybook/preview.ts
import { initialize, mswLoader } from 'msw-storybook-addon';

initialize();

export const loaders = [mswLoader];
// UserProfile.stories.tsx
import { http, HttpResponse } from 'msw';
import { UserProfile } from './UserProfile';

export const LoggedIn: StoryObj = {
  parameters: {
    msw: {
      handlers: [
        // この Story だけ特定のレスポンスを返す
        http.get('/api/me', () => {
          return HttpResponse.json({ name: 'Alice', role: 'admin' });
        }),
      ],
    },
  },
};

export const ApiError: StoryObj = {
  parameters: {
    msw: {
      handlers: [
        http.get('/api/me', () => new HttpResponse(null, { status: 401 })),
      ],
    },
  },
};
エンジニア

Storybook + MSW の組み合わせは、バックエンドが未完成な段階でもフロントエンドの実装を進められるのが大きなメリットです。API の正常系・エラー系・ローディング中を Story として定義しておけば、デザイナーへの確認もブラウザを開くだけで完結します。

サンプルアプリケーション

本記事で紹介したテスト戦略を実際のプロジェクトでどのように適用するか、参考となるサンプルアプリケーションを公開しています。

🌙 laravel-react-boilerplate

Laravel 12 と React 19 を組み合わせた学習用の EC サイトボイラープレートです。Docker によるクリーンな開発環境が整備されており、フロントエンドのテスト環境として Vitest・Testing Library・Storybook が、バックエンドのテストとして PHPUnit が導入済みです。Storybook は http://localhost:6006/ で起動でき、コンポーネントカタログとして即座に利用できます。

主な技術スタックと学べる実装例は以下のとおりです。

  • フロントエンド:React 19 / TypeScript / Tailwind CSS / Storybook / Vitest
  • バックエンド:Laravel 12 / PHP 8.2 / PHPUnit
  • インフラ:Docker Compose / MySQL 8 / Minio(S3互換)/ Mailpit
  • 実装例:Stripe 決済 / ソーシャルログイン(Google)/ マルチログイン認証 / S3 画像アップロード

テスト設計の参考実装としてはもちろん、Laravel + React のフルスタック構成を手元で動かしながら学ぶ出発点としても活用してみてください。

まとめ

本記事で紹介したツールと戦略を組み合わせることで、「壊れにくく、意味のあるテスト」を継続的に維持できる開発体制が整います。各ポイントを改めて整理します。

  • Vitest は Vite のエコシステムと設定を共有でき、エイリアスや環境変数の二重管理を解消できる。
  • テスティングトロフィー に基づき、結合テストを中心に据えることでリファクタリング耐性の高いテスト設計が実現する。
  • MSW を使えばテストケースごとに API レスポンスを柔軟に切り替えられ、正常系・エラー系を網羅したコンポーネントテストが書ける。
  • Playwright はクリティカルなユーザーフローに絞って適用し、Role ベースのロケーターと自動リトライ付きアサーションを活用する。
  • StorybookcomposeStoriesplay 関数を活用することで、コンポーネントカタログとテストコードを一元管理でき、MSW との組み合わせでバックエンドなしの開発・検証も実現できる。
  • 「実装の詳細をテストしない」という原則を守ることが、長期的なメンテナンスコストの削減に直結する。

テストは「書くこと」が目的ではなく、「自信を持ってコードを変更できる状態を維持すること」が本来の目的です。ツールの選定や戦略の設計に迷ったときは、この原点に立ち返ることで、取り組むべき優先順位が自然と見えてくるはずです。

コメントを残す

入力エリアすべてが必須項目です。メールアドレスが公開されることはありません。

内容をご確認の上、送信してください。