はじめに
この記事では、Vitest ブラウザモードと Playwright を比較し、それぞれの特徴と使い分けについて解説します。
どちらもブラウザ上でテストを実行できるツールですが、目的や得意分野が異なります。プロジェクトに適したツールを選択するための参考にしてください。
開発環境
開発環境は以下の通りです。
- Windows 11
- VSCode
- Vite 7.3.1
- React 19.2.3
- TypeScript 5.9.3
- Vitest 4.0.17
- Playwright
両ツールの概要
Vitest ブラウザモード
Vitest は Vite ネイティブなテストフレームワークです。ブラウザモードを使用すると、jsdom などのシミュレーション環境ではなく、実際のブラウザ上でユニットテストやコンポーネントテストを実行できます。
主な用途はコンポーネントの単体テストです。
Playwright
Playwright は Microsoft が開発した E2E(End-to-End)テストフレームワークです。実際のブラウザを操作してアプリケーション全体の動作を検証します。
主な用途はアプリケーション全体の結合テストです。
セットアップの比較
Vitest ブラウザモードのセットアップ
簡単セットアップコマンドを使用すると、必要な依存関係のインストールとブラウザ設定を自動で行えます。
npx vitest init browser
手動でインストールする場合は以下のコマンドを実行します。
npm install -D vitest @vitest/browser-playwright vitest-browser-react
Playwright(E2E テスト)のセットアップ
npm init playwright@latest
ここでの Playwright は E2E テストフレームワークとしての利用です。Vitest ブラウザモードで使用する @vitest/browser-playwright とは別のパッケージです。
インストール時の質問に回答すると、設定ファイルが自動生成されます。
baseURL を http://localhost:5173/ に変更します。
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./src/__tests__/e2e",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('')`. */
baseURL: "http://localhost:5173", // 変更する
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// },
});
テスト対象のコンポーネント
比較のため、同じコンポーネントをテストします。
import { useState } from "react";
type LoginFormProps = {
onSubmit: (email: string, password: string) => void;
};
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!email) {
setError("メールアドレスを入力してください");
return;
}
if (!password) {
setError("パスワードを入力してください");
return;
}
setError("");
onSubmit(email, password);
};
return (
<form onSubmit={handleSubmit}>
<h2>ログイン</h2>
{error && (
<p data-testid="error-message" role="alert">
{error}
</p>
)}
<div>
<label htmlFor="email">メールアドレス</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="example@email.com"
/>
</div>
<div>
<label htmlFor="password">パスワード</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="パスワード"
/>
</div>
<button type="submit">ログイン</button>
</form>
);
}
import { LoginForm } from "./components/LoginForm";
function App() {
const handleLogin = (email: string, password: string) => {
console.log("ログイン:", email, password);
// 実際のアプリケーションではここで認証処理を行う
};
return <LoginForm onSubmit={handleLogin} />;
}
export default App;
テストコードの比較
Vitest ブラウザモードのテストコード
import { expect, test, vi } from "vitest";
import { render } from "vitest-browser-react";
import { LoginForm } from "../../components/LoginForm";
test("フォームが正しく表示される", async () => {
const screen = await render(<LoginForm onSubmit={vi.fn()} />);
await expect
.element(screen.getByRole("heading", { name: "ログイン" }))
.toBeVisible();
await expect.element(screen.getByLabelText("メールアドレス")).toBeVisible();
await expect.element(screen.getByLabelText("パスワード")).toBeVisible();
await expect
.element(screen.getByRole("button", { name: "ログイン" }))
.toBeVisible();
});
test("メールアドレス未入力でエラーが表示される", async () => {
const screen = await render(<LoginForm onSubmit={vi.fn()} />);
await screen.getByRole("button", { name: "ログイン" }).click();
await expect
.element(screen.getByTestId("error-message"))
.toHaveTextContent("メールアドレスを入力してください");
});
test("パスワード未入力でエラーが表示される", async () => {
const screen = await render(<LoginForm onSubmit={vi.fn()} />);
await screen.getByLabelText("メールアドレス").fill("test@example.com");
await screen.getByRole("button", { name: "ログイン" }).click();
await expect
.element(screen.getByTestId("error-message"))
.toHaveTextContent("パスワードを入力してください");
});
test("正しい入力でonSubmitが呼ばれる", async () => {
const handleSubmit = vi.fn();
const screen = await render(<LoginForm onSubmit={handleSubmit} />);
await screen.getByLabelText("メールアドレス").fill("test@example.com");
await screen.getByLabelText("パスワード").fill("password123");
await screen.getByRole("button", { name: "ログイン" }).click();
expect(handleSubmit).toHaveBeenCalledWith("test@example.com", "password123");
});
Playwright のテストコード
import { test, expect } from "@playwright/test";
test("フォームが正しく表示される", async ({ page }) => {
await page.goto("/login");
await expect(page.getByRole("heading", { name: "ログイン" })).toBeVisible();
await expect(page.getByLabel("メールアドレス")).toBeVisible();
await expect(page.getByLabel("パスワード")).toBeVisible();
await expect(page.getByRole("button", { name: "ログイン" })).toBeVisible();
});
test("メールアドレス未入力でエラーが表示される", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "ログイン" }).click();
await expect(page.getByTestId("error-message")).toHaveText(
"メールアドレスを入力してください",
);
});
test("パスワード未入力でエラーが表示される", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("メールアドレス").fill("test@example.com");
await page.getByRole("button", { name: "ログイン" }).click();
await expect(page.getByTestId("error-message")).toHaveText(
"パスワードを入力してください",
);
});
test("正しい入力でログインできる", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("メールアドレス").fill("test@example.com");
await page.getByLabel("パスワード").fill("password123");
await page.getByRole("button", { name: "ログイン" }).click();
// 実際のアプリケーションではログイン後の画面遷移を確認
await expect(page).toHaveURL("/dashboard");
});
テストコードの主な違い
| 観点 | Vitest ブラウザモード | Playwright |
|---|---|---|
| レンダリング |
await render(<Component />) でコンポーネント単体 |
page.goto('/path') でページ全体 |
| 要素取得 | screen.getByRole() |
page.getByRole() |
| アサーション | expect.element() |
expect() |
| モック |
vi.fn() で関数をモック可能 |
API モックは page.route() を使用 |
| テスト範囲 | コンポーネント単位 | ページ・フロー単位 |
テスト実行の比較
Vitest ブラウザモード
# テスト実行
npx vitest
# 単発実行
npx vitest run
# UI モード
npx vitest --ui
✓ chromium src/__tests__/components/LoginForm.test.tsx (4 tests) 672ms
✓ フォームが正しく表示される 87ms
✓ メールアドレス未入力でエラーが表示される 339ms
✓ パスワード未入力でエラーが表示される 95ms
✓ 正しい入力でonSubmitが呼ばれる 149ms
Test Files 1 passed (1)
Tests 4 passed (4)
Start at 11:27:24
Duration 5.86s (transform 0ms, setup 0ms, import 136ms, tests 672ms, environment 0ms)
PASS Waiting for file changes...
press h to show help, press q to quit
Playwright
# テスト実行
npx playwright test
# ヘッドありモード(ブラウザ表示)
npx playwright test --headed
# UI モード
npx playwright test --ui
# レポート表示
npx playwright show-report
Running 12 tests using 4 workers
12 passed (18.1s)
To open last HTML report run:
npx playwright show-report
機能比較表
| 機能 | Vitest ブラウザモード | Playwright |
|---|---|---|
| 主な用途 | コンポーネントテスト | E2E テスト |
| テスト対象 | コンポーネント単体 | アプリケーション全体 |
| 実行速度 | 速い | やや遅い |
| セットアップ | Vite プロジェクトなら簡単 | 独立してセットアップ |
| HMR 対応 | あり | なし |
| スクリーンショット | あり(Visual Regression Testing) | 高機能(差分比較など) |
| トレース | あり(Trace View) | あり(操作の記録・再生) |
| ネットワークモック | 限定的 | 高機能 |
| 複数ブラウザ | 対応 | 対応 |
| 並列実行 | 対応 | 対応 |
| CI 統合 | 容易 | 容易 |
Vitest ブラウザモード固有の機能
Vite との統合
Vite の HMR(Hot Module Replacement)が利用でき、テストファイルの変更が即座に反映されます。
Trace View
Vitest 4.x では Playwright の Trace 機能がサポートされ、テスト実行の詳細な記録を確認できます。
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
plugins: [react()],
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
trace: 'retain-on-failure', // 失敗したテストのみトレースを保持
},
},
})
トレースファイルは __traces__ フォルダに保存され、Playwright Trace Viewer で確認できます。
npx playwright show-trace "path-to-trace-file.zip"
Visual Regression Testing
スクリーンショットベースのビジュアルリグレッションテストが可能です。
import { expect, test } from 'vitest'
import { render } from 'vitest-browser-react'
import { LoginForm } from '../LoginForm'
test('ログインフォームの見た目が正しい', async () => {
const screen = await render(<LoginForm onSubmit={vi.fn()} />)
// コンポーネントのスクリーンショットを撮影して比較
await expect.element(screen.getByTestId('login-form')).toMatchScreenshot('login-form')
})
モジュールモック
vi.mock() でモジュールをモックできます。ブラウザモードでは { spy: true } オプションを使用することで、モジュールのエクスポートを置き換えずにスパイできます。
import { vi } from 'vitest'
import * as auth from '../api/auth'
// spy: true でモジュールをスパイ
vi.mock('../api/auth', { spy: true })
test('ログイン処理が呼ばれる', async () => {
vi.mocked(auth.login).mockResolvedValue({ success: true })
const screen = await render(<LoginForm />)
// ...テスト処理
expect(auth.login).toHaveBeenCalledWith('test@example.com', 'password123')
})
インラインスナップショット
テストファイル内にスナップショットを記述できます。
test('コンポーネントのスナップショット', async () => {
const screen = await render(<LoginForm onSubmit={vi.fn()} />)
expect(screen.container.innerHTML).toMatchInlineSnapshot()
})
Playwright 固有の機能
トレース機能
テスト実行の詳細な記録を取得できます。
export default defineConfig({
use: {
trace: 'on-first-retry',
},
})
スクリーンショット比較
ビジュアルリグレッションテストが可能です。
test('ページのスクリーンショット', async ({ page }) => {
await page.goto('/login')
await expect(page).toHaveScreenshot('login-page.png')
})
ネットワークモック
API レスポンスをモックできます。
test('API エラー時の表示', async ({ page }) => {
await page.route('**/api/login', (route) => {
route.fulfill({
status: 401,
body: JSON.stringify({ error: '認証エラー' }),
})
})
await page.goto('/login')
// エラー表示の確認
})
コードジェネレーター
ブラウザ操作からテストコードを自動生成できます。
npx playwright codegen http://localhost:5173
使い分けの指針
Vitest ブラウザモードを選ぶケース
- コンポーネント単体の動作確認
- Props や State の変化に応じた表示確認
- イベントハンドラの呼び出し確認
- フォームのバリデーションロジック確認
- 高速なフィードバックが必要な場合
Playwright を選ぶケース
- ページ間の遷移を含むフロー確認
- 認証フロー全体のテスト
- API との統合テスト
- 複数ブラウザでの互換性確認
- ビジュアルリグレッションテスト
- 本番環境に近い条件でのテスト
推奨されるテスト戦略
テストピラミッドの考え方に基づき、両ツールを組み合わせて使用することを推奨します。
/\
/ \
/ E2E \ ← Playwright(少数の重要なフロー)
/--------\
/ 統合 \ ← Vitest ブラウザモード(コンポーネント連携)
/--------------\
/ ユニット \ ← Vitest Node.js モード(ロジック)
/------------------\
具体的な配分の目安は以下の通りです。
- ユニットテスト(Vitest Node.js): 70% - ビジネスロジック、ユーティリティ関数
- コンポーネントテスト(Vitest ブラウザ): 20% - UI コンポーネントの動作
- E2E テスト(Playwright): 10% - 重要なユーザーフロー
package.json のスクリプト例
{
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"test": "vitest",
"test:unit": "vitest --project unit",
"test:browser": "vitest --project browser",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:all": "npm run test:unit && npm run test:browser && npm run test:e2e"
}
}
まとめ
この記事では、Vitest ブラウザモードと Playwright の違いについて解説しました。
Vitest ブラウザモードは、実際のブラウザ上でコンポーネント単体をテストするためのツールです。Vite との統合により高速な HMR が利用でき、vi.mock() によるモジュールモックも可能です。Vitest 4.x では Trace View や Visual Regression Testing も追加され、デバッグ機能が強化されています。コンポーネントの Props や State の変化、イベントハンドラの動作確認に適しています。
Playwright は、アプリケーション全体を対象とした E2E テストフレームワークです。ページ遷移を含むユーザーフローのテスト、トレース機能、スクリーンショット比較、ネットワークモックなど、本番環境に近い条件でのテストに強みがあります。


