テスト講座 #6 PlaywrightでE2EとCI統合 — トラックの締めくくり
テストトラックの最終回です。#1〜5 が一つのコンポーネント / 一つのモジュール内に閉じていたのに対し、今回は 本物のブラウザで実際のユーザーシナリオ を最後まで追いかけるE2Eです。
テスト講座 におけるこの記事の位置:
- #1 なぜテストか
- #2 Vitestのセットアップと最初のユニットテスト
- #3 React Testing Library
- #4 非同期とネットワークモッキング — MSW
- #5 ユーザーイベントとフォームのテスト
- #6 PlaywrightでE2EとCI統合 — トラックの締めくくり ← 今回
今回のツールはPlaywright、最後の節でトラック6本全体を振り返ります。
E2Eが捕まえるもの、捕まえないもの #
#1 で示した形をもう一度。E2Eの位置はこちらです。
┌──────────────┐
│ E2E │ ← 主要シナリオ 5〜10個
├──────────────┤
│ 結合 (RTL) │
├──────────────┤
│ ユニット │
├──────────────┤
│ 静的 │
└──────────────┘E2Eが 捕まえるもの:
- コンポーネント結合テストでは見えないもの — ルーティング、認証フロー、ページ間の状態の受け渡し。
- 本物のブラウザ差 — SafariがChromeと違う動きをする細かな部分。
- バックエンドとフロントエンドの契約がずれている部分。
- 本物のネットワーク / 本物のDBが動く環境でしか見えないrace / timing。
E2Eが 捕まえにくい、または費用対効果が悪いもの:
- 単一コンポーネントの動作 — RTLのほうが速くて正確。
- エッジケースの全分岐 — E2E 30個でカバーする分岐をユニット5個でカバーできる。
- サーバーサイドのビジネスロジック — サーバーサイドのユニット/結合テストが本筋。
推奨数 — 主要なユーザーフロー 5〜10個。「会員登録 → ログイン → 主要機能を1〜2個 → 決済」くらい。それ以上に増やすと、メンテナンスがすぐに重荷になります。
Playwright vs Cypress — 短く #
E2Eツールはこの2強です。要点だけ示すと、
| Playwright | Cypress | |
|---|---|---|
| ブラウザ | Chromium, Firefox, WebKit (Safari) | Chromium, Firefox, WebKit (実験的) |
| 実行モデル | Out-of-process(ブラウザ自動化) | In-process(ブラウザ内部) |
| マルチタブ / iframe | 自然にサポート | 扱いにくい |
| 並列実行 | 標準サポート | 有料 (Cypress Cloud) |
| モバイルエミュレーション | 強い | 普通 |
| 学習コスト | 低い | 最も低い |
| ドキュメント | 良い | 非常に良い |
新規プロジェクトでPlaywrightを選ばない理由がほぼない 状況になりました。WebKitサポート、無料の並列実行、Microsoftによる活発な開発。Cypressが強いのは、教えやすいDXとビジュアルデバッガくらいです。
この記事はPlaywrightベースで進めます。
セットアップ #
最も速いスタート — 公式のinitコマンドです。
pnpm create playwright聞かれること:
- TypeScript? → はい
- テストディレクトリ? →
e2e(またはtests) - GitHub Actionsワークフローを追加? → はい(CIの節で詳しく)
- ブラウザのインストール? → はい(Chromium / Firefox / WebKitをダウンロード、約500MB)
生成された playwright.config.ts の一部:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});主要なオプション:
baseURL— すべてのpage.goto('/')の基準。環境ごとに違うURLを環境変数で差し替えます。webServer— テスト開始前に自動でdevサーバーを立ち上げてくれます。CIでも同じ動作です。projects— 同じテストを複数のブラウザで。CIでは3つすべて回すのが定石です。trace: 'on-first-retry'— 最初に失敗したテストだけtraceを記録(スクリーンショット + DOMスナップショット + ネットワーク)。成功ケースは記録しないので結果が軽くなります。retries— flakyなテストの自動リトライ。CIでのみ有効。
最初のE2E #
最も単純なテストです。
import { test, expect } from '@playwright/test';
test('ホームページがタイトルを表示する', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/My App/);
});
test('ログインリンクをクリックするとログインページへ遷移する', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'ログイン' }).click();
await expect(page).toHaveURL('/login');
});{ page } はテストごとに新しいブラウザコンテキストです。クリーンなクッキー / ストレージで開始します。
queriesはRTLとほぼ同じ形 — getByRole、getByLabel、getByText。同じ哲学を共有しています。
pnpm playwright test # headless
pnpm playwright test --headed # ブラウザを表示
pnpm playwright test --ui # ビジュアルUIモード(強力)
pnpm playwright test --debug # デバッグモード(ステップ実行)--ui モードを一度立ち上げてみてください。左にテスト一覧、中央にブラウザ画面、右にアクションタイムライン。デバッグの体験ががらりと変わります。
Locator — 単なるelementではない #
Playwrightの getByRole(...) などは、実は Locator オブジェクトを返します。RTLの結果は即座にelementですが、Playwrightはlazyです — 実際にアクションが起きるまでelementを探しません。
const button = page.getByRole('button', { name: '保存' });
// この時点ではまだDOMを見ていない
await button.click();
// このタイミングでelementを探し、見つからなければ自動で待つ
この構造のおかげで 自動待機(auto-waiting) が自然になります。RTLの waitFor / findBy を使う必要はほぼありません — await 自体がelementを待ってくれるためです。
await page.getByRole('button', { name: '保存' }).click();
// ボタンが現れるのを待ち、enabledになるのを待ってクリック
await expect(page.getByRole('alert')).toHaveText('保存しました');
// alertが現れるのを待ち、テキストが一致するまで待つ
expect も同じ形です。await expect(locator).toBeVisible() はデフォルトで5秒間ポーリングしながら待ち、最後まで満たされなければそこで失敗します。
ログインフロー — 本物のE2E #
もう少し本格的なシナリオです。ログイン → ダッシュボード → ログアウト。
import { test, expect } from '@playwright/test';
test.describe('認証フロー', () => {
test('間違ったパスワードでログインするとエラーが表示される', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('メール').fill('user@example.com');
await page.getByLabel('パスワード').fill('wrong');
await page.getByRole('button', { name: 'ログイン' }).click();
await expect(page.getByRole('alert')).toContainText('パスワード');
await expect(page).toHaveURL('/login'); // ページ遷移なし
});
test('正しい資格情報でログインするとダッシュボードへ遷移する', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('メール').fill('user@example.com');
await page.getByLabel('パスワード').fill('correct-password');
await page.getByRole('button', { name: 'ログイン' }).click();
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading', { name: /ようこそ/ })).toBeVisible();
});
test('ダッシュボードからログアウトするとホームに戻る', async ({ page }) => {
// 事前ログイン
await page.goto('/login');
await page.getByLabel('メール').fill('user@example.com');
await page.getByLabel('パスワード').fill('correct-password');
await page.getByRole('button', { name: 'ログイン' }).click();
await expect(page).toHaveURL('/dashboard');
// ログアウト
await page.getByRole('button', { name: 'ログアウト' }).click();
await expect(page).toHaveURL('/');
});
});3つ目のテストが面白いところです — 毎回事前ログインを繰り返さなければなりません。E2Eがすぐに重荷になる部分です。次の節で解決します。
storageState — ログイン状態の共有
#
毎テストでログインフローを回すのは無駄です。Playwrightの storageState がこれを解決します。
import { test as setup } from '@playwright/test';
const authFile = '.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('メール').fill('user@example.com');
await page.getByLabel('パスワード').fill('correct-password');
await page.getByRole('button', { name: 'ログイン' }).click();
await page.waitForURL('/dashboard');
// クッキーとlocalStorageをファイルに保存
await page.context().storageState({ path: authFile });
});projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' },
dependencies: ['setup'],
},
],これですべてのchromiumテストが すでにログイン済みの状態 で開始します。setupは1回だけ動き、本テストたちはその結果を共有します。
ネットワークモッキング — Playwrightでも可能 #
E2Eだからといって常に本物のバックエンドを使う必要はありません。一部のシナリオ(エラーレスポンス、遅いレスポンス)はモッキングが自然です。
test('サーバーが500を返すとエラー画面が出る', async ({ page }) => {
await page.route('/api/posts', (route) => {
route.fulfill({ status: 500, body: 'Internal Error' });
});
await page.goto('/posts');
await expect(page.getByRole('alert')).toContainText('サーバーエラー');
});page.route(pattern, handler) は #4 のMSWに似た位置です。ただしE2Eの本来の価値は 本物のバックエンドの検証 にあります。モッキングは普段は使わず、エラーパス / エッジケースを1〜2個程度に抑えるのがおすすめです。
Page Object パターン — 軽めに #
テストが増えると、同じselectorをテストごとに繰り返すことになります。Page Objectパターンがここを整理してくれます。
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly alert: Locator;
constructor(public readonly page: Page) {
this.emailInput = page.getByLabel('メール');
this.passwordInput = page.getByLabel('パスワード');
this.submitButton = page.getByRole('button', { name: 'ログイン' });
this.alert = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test('ログインフロー', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'correct-password');
await expect(page).toHaveURL('/dashboard');
});selectorが一箇所にまとまり、変更時は1箇所だけ修正すれば済みます。1つだけ注意 — 過度な抽象化はかえって可読性を損ねます。selectorとよく使うアクション までをPOに。検証(expect)はテストの中に置いたほうが、たいていは見通しが良いです。
CI統合 — GitHub Actions #
pnpm create playwright が自動で生成してくれるワークフローです。
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps
- name: Run tests
run: pnpm exec playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30要点:
playwright install --with-deps— ブラウザ + システム依存関係をまとめてインストール。CIごとにキャッシュできる部分なので、キャッシュアクションを追加するとさらに速くなります。upload-artifactwithif: always()— テストが落ちたときにHTMLレポートを成果物としてアップロード。落ちたテストのtrace / screenshotをPRからダウンロードして確認できます。
Vitestも一緒に — 1つのワークフローに両方 #
E2Eとユニット / 結合を同じワークフローに収めます。
name: Tests
on:
push:
branches: [main]
pull_request:
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- uses: pnpm/action-setup@v4
- run: pnpm install
- run: pnpm test:run --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- uses: pnpm/action-setup@v4
- run: pnpm install
- run: pnpm exec playwright install --with-deps
- run: pnpm exec playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/並列で回るのでunit(数十秒)とe2e(数分)が同時に終わります — PRのフィードバックサイクルが短縮されます。
カバレッジ — どこまで見せるか #
VitestのカバレッジレポートがCIに上がると、GitHub PRに自動でコメントを付けてくれるアクションがよく使われます。
- uses: davelosert/vitest-coverage-report-action@v2
if: github.event_name == 'pull_request'PRに「このPRでカバレッジが X% → Y% に変化」のようなコメントが自動で付きます。
#1、#2 でも触れた話 — 数字に振り回されないこと。カバレッジが下がったら自動でマージブロック、のようなルールはたいてい自分の足を撃つことになります。主要なフローが押さえられているかのほうが大切です。
Visualリグレッション — 一行 #
画面が意図に反して変わっていないかを捕まえたいなら — Playwrightの toHaveScreenshot() です。
test('ホームページのビジュアルリグレッション', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('home.png');
});初回実行時にbaselineスクリーンショットが保存され、以降の実行で差分があれば失敗します。CIのOS / フォントの違いがfalse positiveをよく引き起こすので、通常は同じOSでbaselineを作り、それだけと比較します。
規模が小さいプロジェクトでは、わざわざ導入しなくてもOKです。大きくなったらPercy / ChromaticのようなSaaSのほうがすっきりします。
よくある落とし穴 #
flakyなテストが多すぎる — 多くは setTimeout / network race / animationが原因です。Playwrightの expect(...).toBe...() が自動待機を行うため、page.waitForTimeout(1000) のようなfixed sleepはほぼ常にアンチパターンです。
テストはパスするのにcookies / storageが漏れているように見える — storageState が正しく設定されていないか、test.use({ storageState: ... }) と dependencies の組み合わせがズレているケースです。
CIでだけ落ちる — たいていtiming / 画面サイズ / fontsです。playwright.config.ts の viewport と baseURL がCIとローカルで同じか確認しましょう。
.toHaveText() がテキストの一部で失敗 — toContainText() と取り違えています。完全一致は toHaveText、部分一致は toContainText。
並列実行でデータ競合 — 同じテストユーザーが同時に動いている流れです。テストごとに別データを使うか(メールにtimestampを足すなど)、test.describe.configure({ mode: 'serial' }) で直列化します。
WebKitだけ落ちる — Safariの微妙な互換性の差です。playwright.config.ts で一時的に projects からwebkitを外し、別途デバッグします。たいていはCSS / transform / focus関連です。
トラック6本の振り返り #
#1 の図から始め — 静的 → 結合 → ユニット → E2E の配分に沿って進めてきました。6本がそれぞれ触れたところ:
#1 — 図(ピラミッド vs トロフィー)
#2 — Vitest(ユニット + そのセットアップ)
#3 — RTL(結合への第一歩)
#4 — MSW(ネットワーク傍受 = 結合の核)
#5 — userEvent + フォーム(ユーザー入力の結合)
#6 — Playwright + CI(E2E + 自動化)そして最初に示した一文に戻ると、
テストができない理由はたいてい「忙しいから」ではなく、何を / どこで / どうやって の絵がないからです。
その絵がもう手元にあります。小さな関数にユニットテストを書くか、コンポーネント結合に進むか、E2Eシナリオに入れるか — 判断のガイドが手の中にあります。ここから先に進むところ:
- 状態管理の深掘りトラック — TanStack Query / Zustandのようなツールのテストパターン。
- バックエンドのテスト — Pythonトラックの
pytest、Django中級 #7 テスト、モダンPython実践 #6 が該当箇所です。 - コントラクトテスト — バックエンドとフロントエンドの契約をOpenAPI / Pactで検証。
- mutation testing — テストが「本当に捕まえているか」を検証するメタツール(Stryker)。
まとめ #
- E2Eは 主要なユーザーフロー 5〜10個 — それ以上はメンテナンスが負担になります。ユニット / 結合が見るべきものはそちらに任せましょう。
- Playwrightのlocatorはlazy + auto-waiting。
waitForはほぼ不要 —await expect(locator).toBe...()が自動でポーリングします。 - queriesはRTLと同じ哲学 —
getByRole、getByLabelを優先。ユーザーが見る方法に揃えます。 storageStateでログイン状態を共有 — 毎テストでログインフローを繰り返さないようにします。page.routeでPlaywright内でもネットワークモッキングが可能。ただしE2Eの本来の価値は本物のバックエンドの検証にあります。- CIワークフローは unit + e2e の並列。trace / reportをartifactとしてアップロードし、PRからダウンロードできるようにしましょう。
- カバレッジは 参考、自動ブロックのルールはたいてい自分の足を撃つことになります。
- トラック6本の形 — 静的 → 結合 → ユニット → E2E。時間の配分を意識的に。
テストトラックはここで締めくくります。Reactトラック / TypeScriptトラック からの自然な拡張先でした。このトラックを終えれば、ほぼすべてのReactコードに対してテストを書く感覚が手に馴染んでいるはずです。次の一歩は — 自分のプロジェクトの一つに実際に導入してみる段階。小さな関数1〜2個から始めましょう。