テストピラミッド vs テスティングトロフィー: モダンフロントエンドのテスト戦略ガイド
「どのテストをどれくらい書くべき?」その悩みを解決。テストピラミッドとテスティングトロフィーの違いを理解し、React/TypeScript/Next.jsプロジェクトに最適なテスト戦略を実例付きで解説します。
ソフトウェアエンジニア
作成日:

「レスポンシブ対応が正しく動作しているか、毎回目視で確認するのが面倒...」
「jsdomでテストを書いているけど、CSSが適用されないから見た目のテストができない」
「Storybookで確認してるけど、自動テストに組み込みたい」
こんな悩みを抱えていませんか?
フロントエンドのテストでは、Vitest + Testing Library(jsdom) で多くのケースをカバーできます。しかし、jsdomは実際のブラウザではないため、CSSレイアウト、ビューポート、レスポンシブ対応といった「見た目」に関するテストには限界があります。
一方、Playwright E2Eを使えば実ブラウザでテストできますが、ページ全体を対象とするため、コンポーネント単位の細かいテストにはオーバーヘッドが大きくなります。
この記事では、jsdomとE2Eの間を埋める選択肢として、Playwright Component Testingを紹介します。特にレスポンシブ対応のテストなど、実践的なユースケースを中心に解説していきます。
まず、各テストアプローチの特徴を整理してみましょう。
| 観点 | jsdom / happy-dom | Component Testing | E2E (Playwright) |
|---|---|---|---|
| 実行環境 | Node.js (DOMエミュレーション) | 実ブラウザ | 実ブラウザ |
| CSSの適用 | 限定的 | 完全 | 完全 |
| レイアウト計算 | 不可 | 可能 | 可能 |
| ビューポート制御 | 模擬的 | 実際のサイズ変更 | 実際のサイズ変更 |
| 実行速度 | 非常に速い | 速い | 遅い |
| 対象範囲 | コンポーネント | コンポーネント | ページ全体 |
| セットアップ | 簡単 | 中程度 | 複雑 |
jsdom(およびhappy-dom)は高速で便利ですが、以下のような制限があります。これらは実際のブラウザエンジンではなく、DOMのエミュレーションであるためです。
// jsdomでのテスト例
import { render, screen } from '@testing-library/react';
import { ResponsiveNav } from './ResponsiveNav';
test('モバイルでハンバーガーメニューが表示される', () => {
// ❌ jsdomではwindow.innerWidthを変更しても
// CSSメディアクエリは反応しない
window.innerWidth = 375;
render(<ResponsiveNav />);
// このテストは期待通りに動作しない
expect(screen.getByRole('button', { name: 'メニュー' })).toBeVisible();
});jsdomではwindow.innerWidthを変更しても、CSSのメディアクエリは反応しません。レスポンシブ対応のテストには、実際のブラウザが必要です。
一方、E2Eでコンポーネントのレスポンシブテストを行う場合、以下のような課題があります。
Playwright Component Testingは、この中間を埋める存在です。実ブラウザの環境を使いながら、コンポーネント単位で軽量にテストできます。
Playwright Component Testingは、Playwrightチームが提供する実験的機能です。コンポーネントを実際のブラウザ上でマウントし、Playwrightの強力なAPIを使ってテストできます。
注意: 2025年12月時点で実験的(experimental)機能です。本番での採用は、プロジェクトの要件と安定性を考慮して判断してください。
@playwright/experimental-ct-react@playwright/experimental-ct-vue@playwright/experimental-ct-sveltePlaywright Component Testingは、内部でViteを使用してコンポーネントをバンドルし、実際のブラウザ上でレンダリングします。これにより、実ブラウザの環境でコンポーネント単体をテストできます。
# npm
npm init playwright@latest -- --ct
# yarn
yarn create playwright --ct
# pnpm
pnpm create playwright --ct注意:
--ctフラグを忘れると通常のPlaywright E2Eがインストールされます。既存のPlaywrightプロジェクトがある場合は、playwright-ct.config.tsが別ファイルとして作成されます。
インストールコマンドを実行すると、以下のファイルが生成されます。
playwright/
├── index.html # コンポーネントをレンダリングするHTML
└── index.ts # グローバルスタイルやテーマの設定<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Testing</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.ts"></script>
</body>
</html>// グローバルスタイルの読み込み
import '../src/styles/globals.css';
// テーマプロバイダーの設定(必要に応じて)
import { beforeMount } from '@playwright/experimental-ct-react/hooks';
beforeMount(async ({ App }) => {
// テスト前の共通セットアップ
});import { defineConfig, devices } from '@playwright/experimental-ct-react';
export default defineConfig({
testDir: './src',
testMatch: '**/*.spec.tsx',
use: {
ctPort: 3100,
ctViteConfig: {
// Viteの設定をカスタマイズ
},
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 13'] },
},
],
});ここからは、実際のテストコード例を見ていきます。まずは、レスポンシブ対応のテストです。
以下のようなレスポンシブなナビゲーションコンポーネントを想定します。
// ResponsiveNav.tsx
import styles from './ResponsiveNav.module.css';
interface Props {
items: { label: string; href: string }[];
}
export function ResponsiveNav({ items }: Props) {
return (
<nav className={styles.nav}>
{/* デスクトップ: 通常のメニュー */}
<ul className={styles.desktopMenu}>
{items.map((item) => (
<li key={item.href}>
<a href={item.href}>{item.label}</a>
</li>
))}
</ul>
{/* モバイル: ハンバーガーメニュー */}
<button className={styles.hamburger} aria-label="メニュー">
<span className={styles.hamburgerIcon} />
</button>
</nav>
);
}/* ResponsiveNav.module.css */
.desktopMenu {
display: flex;
gap: 1rem;
}
.hamburger {
display: none;
}
/* モバイル (768px以下) */
@media (max-width: 768px) {
.desktopMenu {
display: none;
}
.hamburger {
display: block;
}
}// ResponsiveNav.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { ResponsiveNav } from './ResponsiveNav';
const menuItems = [
{ label: 'ホーム', href: '/' },
{ label: '製品', href: '/products' },
{ label: 'お問い合わせ', href: '/contact' },
];
test.describe('ResponsiveNav', () => {
test('デスクトップではメニューが表示される', async ({ mount, page }) => {
// ビューポートをデスクトップサイズに設定
await page.setViewportSize({ width: 1280, height: 720 });
const component = await mount(
<ResponsiveNav items={menuItems} />
);
// デスクトップメニューが表示されている
await expect(component.getByRole('link', { name: 'ホーム' })).toBeVisible();
await expect(component.getByRole('link', { name: '製品' })).toBeVisible();
// ハンバーガーメニューは非表示
await expect(component.getByRole('button', { name: 'メニュー' })).toBeHidden();
});
test('モバイルではハンバーガーメニューが表示される', async ({ mount, page }) => {
// ビューポートをモバイルサイズに設定
await page.setViewportSize({ width: 375, height: 667 });
const component = await mount(
<ResponsiveNav items={menuItems} />
);
// ハンバーガーメニューが表示されている
await expect(component.getByRole('button', { name: 'メニュー' })).toBeVisible();
// デスクトップメニューは非表示
await expect(component.getByRole('link', { name: 'ホーム' })).toBeHidden();
});
test('複数のビューポートサイズで検証する', async ({ mount, page }) => {
const component = await mount(
<ResponsiveNav items={menuItems} />
);
// 境界値テストを含む複数のビューポートサイズ
// CSSブレイクポイント(768px)付近を重点的に検証
const viewports = [
{ width: 320, height: 568, expectedMobile: true }, // iPhone SE
{ width: 768, height: 1024, expectedMobile: true }, // iPad(境界値: max-width: 768px に含まれる)
{ width: 769, height: 1024, expectedMobile: false }, // 境界値+1(off-by-oneエラー防止)
{ width: 1920, height: 1080, expectedMobile: false }, // デスクトップ
];
for (const { width, height, expectedMobile } of viewports) {
await page.setViewportSize({ width, height });
const hamburger = component.getByRole('button', { name: 'メニュー' });
if (expectedMobile) {
await expect(hamburger).toBeVisible();
} else {
await expect(hamburger).toBeHidden();
}
}
});
});page.setViewportSize()で実際のビューポートサイズを変更できる実ブラウザでは、要素の実際の位置やサイズを取得できます。
// Card.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Card } from './Card';
// Cardコンポーネントは <article> タグで実装されている想定
// セマンティックなHTML要素を使うことで、getByRoleでアクセス可能
test.describe('Card レイアウト', () => {
test('カードが横並びで表示される(デスクトップ)', async ({ mount, page }) => {
await page.setViewportSize({ width: 1280, height: 720 });
const component = await mount(
<div style={{ display: 'flex', gap: '16px' }}>
<Card title="カード1" />
<Card title="カード2" />
<Card title="カード3" />
</div>
);
// セマンティックなロールで要素を取得(data-testidは避ける)
const cards = component.getByRole('article');
// 3つのカードが存在する
await expect(cards).toHaveCount(3);
// 各カードの位置を取得
const boxes = await cards.evaluateAll((elements) =>
elements.map((el) => el.getBoundingClientRect())
);
// カードが横並びになっている(Y座標が同じ)
expect(boxes[0].top).toBe(boxes[1].top);
expect(boxes[1].top).toBe(boxes[2].top);
// カードが左から右に並んでいる
expect(boxes[0].left).toBeLessThan(boxes[1].left);
expect(boxes[1].left).toBeLessThan(boxes[2].left);
});
test('カードが縦並びで表示される(モバイル)', async ({ mount, page }) => {
await page.setViewportSize({ width: 375, height: 667 });
const component = await mount(
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<Card title="カード1" />
<Card title="カード2" />
</div>
);
const cards = component.getByRole('article');
const boxes = await cards.evaluateAll((elements) =>
elements.map((el) => el.getBoundingClientRect())
);
// カードが縦並びになっている
expect(boxes[0].top).toBeLessThan(boxes[1].top);
expect(boxes[0].left).toBe(boxes[1].left);
});
});test('ホバー時にカードの影が変わる', async ({ mount }) => {
const component = await mount(<Card title="ホバーテスト" />);
// getByRoleでarticle要素を取得
const card = component.getByRole('article');
// 初期状態のスタイルを取得
const initialShadow = await card.evaluate((el) =>
window.getComputedStyle(el).boxShadow
);
// ホバー
await card.hover();
// ホバー後のスタイルを取得
const hoverShadow = await card.evaluate((el) =>
window.getComputedStyle(el).boxShadow
);
// スタイルが変わっていることを確認
expect(hoverShadow).not.toBe(initialShadow);
});Playwright Component Testingでは、スクリーンショットを使ったビジュアルリグレッションテストも可能です。
// Button.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './Button';
test.describe('Button ビジュアルテスト', () => {
test('各バリアントのスナップショット', async ({ mount }) => {
const variants = ['primary', 'secondary', 'danger'] as const;
for (const variant of variants) {
const component = await mount(
<Button variant={variant}>ボタン</Button>
);
await expect(component).toHaveScreenshot(`button-${variant}.png`);
}
});
test('複数ビューポートでのスナップショット', async ({ mount, page }) => {
const component = await mount(
<Button variant="primary" fullWidth>
レスポンシブボタン
</Button>
);
const viewports = [
{ name: 'mobile', width: 375, height: 667 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1280, height: 720 },
];
for (const { name, width, height } of viewports) {
await page.setViewportSize({ width, height });
await expect(component).toHaveScreenshot(`button-${name}.png`);
}
});
});# スナップショットを更新
npx playwright test --update-snapshotsComponent Testingでは、E2Eと同じAPIでユーザーインタラクションをテストできます。
// Dropdown.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Dropdown } from './Dropdown';
test.describe('Dropdown', () => {
const options = ['オプション1', 'オプション2', 'オプション3'];
test('クリックでドロップダウンが開く', async ({ mount }) => {
const component = await mount(
<Dropdown options={options} placeholder="選択してください" />
);
// 初期状態ではオプションが非表示
await expect(component.getByRole('option')).toHaveCount(0);
// トリガーをクリック
await component.getByRole('button', { name: '選択してください' }).click();
// オプションが表示される
await expect(component.getByRole('option')).toHaveCount(3);
await expect(component.getByRole('option', { name: 'オプション1' })).toBeVisible();
});
test('オプションを選択できる', async ({ mount }) => {
let selectedValue = '';
const component = await mount(
<Dropdown
options={options}
placeholder="選択してください"
onChange={(value) => { selectedValue = value; }}
/>
);
await component.getByRole('button', { name: '選択してください' }).click();
await component.getByRole('option', { name: 'オプション2' }).click();
// コールバックが呼ばれる
expect(selectedValue).toBe('オプション2');
// ドロップダウンが閉じる
await expect(component.getByRole('option')).toHaveCount(0);
// 選択値が表示される
await expect(component.getByRole('button', { name: 'オプション2' })).toBeVisible();
});
test('Escキーでドロップダウンが閉じる', async ({ mount }) => {
const component = await mount(
<Dropdown options={options} placeholder="選択してください" />
);
await component.getByRole('button', { name: '選択してください' }).click();
await expect(component.getByRole('option')).toHaveCount(3);
// Escキーを押す
await component.press('Escape');
// ドロップダウンが閉じる
await expect(component.getByRole('option')).toHaveCount(0);
});
});| ユースケース | 説明 |
|---|---|
| レスポンシブ対応 | ビューポートサイズによる表示切り替え |
| CSSレイアウト検証 | Flexbox/Gridの配置確認 |
| ビジュアルリグレッション | UIの意図しない変更を検出 |
| ブラウザ固有の動作 | CSS変数、カスタムプロパティの動作確認 |
| アニメーション | CSSトランジション、アニメーションの検証 |
| アクセシビリティ | フォーカス順序、スクリーンリーダー対応 |
| ユースケース | 代替手段 |
|---|---|
| 純粋なロジックテスト | 単体テスト(Vitest) |
| APIとの連携 | 結合テスト(Testing Library + MSW) |
| 認証フロー全体 | E2Eテスト(Playwright) |
| 複数ページをまたぐフロー | E2Eテスト |
2025年12月時点で、Playwright Component Testingは実験的機能です。APIの変更やブレイキングチェンジの可能性があります。
コンポーネントへのProps渡しには制限があります。
// ❌ 複雑なオブジェクトは渡せない
const component = await mount(
<MyComponent
callback={() => console.log('called')} // 関数は制限あり
complexObject={new Map()} // 特殊なオブジェクトも制限
/>
);
// ✅ プレーンなオブジェクトと組み込み型は OK
const component = await mount(
<MyComponent
title="タイトル"
items={['a', 'b', 'c']}
config={{ enabled: true, count: 5 }}
/>
);この制限に対応するには、テスト用の「ストーリー」コンポーネントを作成することが推奨されています。
// Button.stories.tsx(テスト用ラッパー)
export function PrimaryButton() {
return <Button variant="primary" onClick={() => alert('clicked')}>Primary</Button>;
}
// Button.spec.tsx
import { PrimaryButton } from './Button.stories';
test('Primaryボタンが表示される', async ({ mount }) => {
const component = await mount(<PrimaryButton />);
await expect(component).toBeVisible();
});コンポーネント内でAPIを呼び出す場合は、モックが必要です。
test('データを取得して表示する', async ({ mount, page }) => {
// ネットワークリクエストをモック
await page.route('<strong>/api/users', async (route) => {
await route.fulfill({
status: 200,
json: [{ id: 1, name: 'テストユーザー' }],
});
});
const component = await mount(<UserList />);
await expect(component.getByText('テストユーザー')).toBeVisible();
});Playwright Component Testingは、jsdomでは再現できないテストを、E2Eよりも軽量に実行できるテストアプローチです。
テスト戦略全体の中では、以下のような位置づけになります。
| テスト種類 | 主な用途 |
|---|---|
| 単体テスト | ビジネスロジック、ユーティリティ関数 |
| 結合テスト(jsdom) | UIの振る舞い、ユーザーインタラクション |
| Component Testing** | CSS/レイアウト、レスポンシブ、ビジュアル |
| E2E | クリティカルなユーザーフロー |
テスト戦略の考え方については、以下の記事も参考にしてください。
また、Playwright E2Eテストの設計パターンについては、こちらの記事で詳しく解説しています。
jsdomでのテストについては、こちらの記事が参考になります。
実験的機能ではありますが、特定のユースケースでは非常に有効なアプローチです。ぜひプロジェクトの要件に合わせて検討してみてください。
お客様と一緒に課題を整理し、小さく始めて育てる「共創型開発」を行っています。
「こんなシステムは作れる?」「費用感を知りたい」など、どんな段階でもお気軽にご相談ください。
初回のご相談は無料です。
お問い合わせ