• ホーム
  • ブログ
  • Playwright Component Testing入門:jsdomでは再現できないテストを実ブラウザで

Playwright Component Testing入門:jsdomでは再現できないテストを実ブラウザで

鶴田 篤広のプロフィール画像

鶴田 篤広

ソフトウェアエンジニア

作成日:

PlaywrightComponent TestingReactTypeScriptテストレスポンシブ
Playwright Component Testing入門:jsdomでは再現できないテストを実ブラウザで - jsdomでは再現できないCSS/レイアウト/ビューポートのテストを、E2Eより軽量に実行できるPl...

TL;DR

  • Playwright Component Testing = 実ブラウザでコンポーネント単体をテスト(実験的機能)
  • jsdomの限界を突破: CSS/レイアウト/ビューポートなど、実ブラウザでしか確認できないテストが可能
  • E2Eより軽量: ページ全体ではなくコンポーネント単位でテストするため、セットアップが軽い
  • 最適なユースケース: レスポンシブ対応の確認、ビジュアルリグレッション(スクリーンショット比較によるUI変更検出)、ブラウザ固有の動作検証

はじめに

「レスポンシブ対応が正しく動作しているか、毎回目視で確認するのが面倒...」

「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の限界

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のオーバーヘッド

一方、E2Eでコンポーネントのレスポンシブテストを行う場合、以下のような課題があります。

  • ページ全体をレンダリングするため、テストごとにセットアップが重い
  • 認証やルーティングなど、テスト対象外の部分も準備が必要
  • 1つのコンポーネントをテストするために多くの依存関係が発生

Playwright Component Testingは、この中間を埋める存在です。実ブラウザの環境を使いながら、コンポーネント単位で軽量にテストできます。

Playwright Component Testingとは

Playwright Component Testingは、Playwrightチームが提供する実験的機能です。コンポーネントを実際のブラウザ上でマウントし、Playwrightの強力なAPIを使ってテストできます。

注意: 2025年12月時点で実験的(experimental)機能です。本番での採用は、プロジェクトの要件と安定性を考慮して判断してください。

サポートフレームワーク

  • React: @playwright/experimental-ct-react
  • Vue: @playwright/experimental-ct-vue
  • Svelte: @playwright/experimental-ct-svelte

内部の仕組み

Playwright 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      # グローバルスタイルやテーマの設定

playwright/index.html

<!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>

playwright/index.ts

// グローバルスタイルの読み込み
import '../src/styles/globals.css';
 
// テーマプロバイダーの設定(必要に応じて)
import { beforeMount } from '@playwright/experimental-ct-react/hooks';
 
beforeMount(async ({ App }) => {
  // テスト前の共通セットアップ
});

playwright-ct.config.ts

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()実際のビューポートサイズを変更できる
  • CSSメディアクエリが正しく反応することを確認できる
  • 境界値(768pxなど)のテストも容易

実践例: CSSレイアウトの検証

実ブラウザでは、要素の実際の位置やサイズを取得できます。

要素の位置とサイズの検証

// 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);
  });
});

CSSスタイルの検証

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-snapshots

ユーザーインタラクションのテスト

Component 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);
  });
});

Component Testingが輝くユースケース

適しているケース

ユースケース 説明
レスポンシブ対応 ビューポートサイズによる表示切り替え
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よりも軽量に実行できるテストアプローチです。

  • レスポンシブ対応のテスト: 実際のビューポートサイズでCSSメディアクエリの動作を確認
  • CSSレイアウトの検証: 要素の位置やサイズを正確に取得
  • ビジュアルリグレッション: スクリーンショット比較でUIの変更を検出

テスト戦略全体の中では、以下のような位置づけになります。

テスト種類 主な用途
単体テスト ビジネスロジック、ユーティリティ関数
結合テスト(jsdom) UIの振る舞い、ユーザーインタラクション
Component Testing** CSS/レイアウト、レスポンシブ、ビジュアル
E2E クリティカルなユーザーフロー

テスト戦略の考え方については、以下の記事も参考にしてください。

テストピラミッド vs テスティングトロフィー: モダンフロントエンドのテスト戦略ガイド

テストピラミッド vs テスティングトロフィー: モダンフロントエンドのテスト戦略ガイド

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

また、Playwright E2Eテストの設計パターンについては、こちらの記事で詳しく解説しています。

PlaywrightのPage ObjectパターンとFixturesで実現するスケーラブルなE2Eテスト設計

PlaywrightのPage ObjectパターンとFixturesで実現するスケーラブルなE2Eテスト設計

PlaywrightのPage ObjectパターンとFixturesを組み合わせた実践的なE2Eテスト設計手法を解説。型安全なフィクスチャ定義、認証状態の効率的な管理、階層化されたPage Object設計など、大規模プロジェクトで活用できるベストプラクティスを紹介します。

jsdomでのテストについては、こちらの記事が参考になります。

Reactテストの困りごとを解決: Vitest & Testing Libraryベストプラクティス

Reactテストの困りごとを解決: Vitest & Testing Libraryベストプラクティス

「ESMモジュールのモッキングがうまくいかない」「どのクエリを使うべきか迷う」そんなReactテストの困りごとを、vi.hoistedパターンとTesting Libraryのベストプラクティスで解決します。

実験的機能ではありますが、特定のユースケースでは非常に有効なアプローチです。ぜひプロジェクトの要件に合わせて検討してみてください。

参考文献

こんなお悩みはありませんか?

1

システムの改修・刷新

古いシステムを使い続けているが、そろそろ限界を感じている

2

技術の相談相手がいない

社内にエンジニアがおらず、技術的な判断を相談できる人がいない

3

新規サービスを小さく始めたい

アイデアはあるが、まずは最小限の形で試してみたい

4

業務の効率化・自動化

手作業やExcel管理から脱却し、業務をシステム化したい

5

AIを活用したい

ChatGPTなどのAIを業務に取り入れたいが、どう始めればいいかわからない

このようなお悩みをお持ちの企業様に、
クレインテックが伴走支援いたします。

初回のご相談・お見積もりは無料です。

この記事をシェア

クレインテックに相談する

お客様と一緒に課題を整理し、小さく始めて育てる「共創型開発」を行っています。

「こんなシステムは作れる?」「費用感を知りたい」など、どんな段階でもお気軽にご相談ください。

初回のご相談は無料です。

お問い合わせ