Playwright フレーキーE2Eテスト対策完全ガイド|auto-wait・分離・Trace Viewer・リトライ・ネットワークモック
Playwright E2Eのフレーキー対策は auto-waitingを信頼し、固定sleepを排除 し、テスト間の状態を完全分離 し、外部ネットワークをモック し、失敗時だけTrace Viewerを残す の5点セットが2026年の定石。locator + expect() を基本に、playwright.config.ts の retries はCI限定の深層防御として使い、根本原因はトレースとメトリクスで潰す。
フレーキーE2Eテストがチームを蝕む理由
E2E(End-to-End)テストは、本番に近い環境でユーザーフロー全体を検証できる強力な安全網です。一方で フレーキー(flaky) — 同じコミットなのに green になったり red になったりするテスト — が混ざると、チームは次の悪循環に陥ります。
- CIが赤い → 「またフレーキーでしょ」とスルーされる
- 本当の回帰がmergeされる
- テストへの信頼が下がり、@skip や test.fixme が増える
- E2Eカバレッジが下がり、本番障害が増える
2026年時点で Playwright は Cypress や Selenium 系と並び主流の E2E フレームワークの一つですが、フレームワークを選んでもフレーキーは自動では消えません。Playwright が提供する auto-waiting・Trace Viewer・network routing を 設計として組み込む ことが、安定したパイプラインの前提になります。
| フレーキーが多いチーム | 対策済みチーム |
|---|---|
| sleep(3000) が everywhere | expect(locator).toBeVisible() の条件待機 |
| 全テストが同一 admin でログイン | worker ごとに独立ユーザー・テナント |
| 本番 API を E2E から直叩き | route.fulfill で決定的レスポンス |
| 失敗ログはスクショ1枚だけ | Trace Viewer で DOM・ネットワーク再生 |
| リトライ無制限で green 偽装 | リトライ率メトリクス + quarantine 運用 |
Playwrightのauto-waiting:フレーキー削減の中核
Playwright の最大の強みの一つが auto-waiting(自動待機) です。locator.click() や locator.fill() は、内部的に次の条件が満たされるまで待ってから操作します。
- 要素が DOM に存在する
- 要素が表示されている(visible)
- 要素が stable(アニメーション等で動いていない)
- 要素が receive events 可能(他要素に覆われていない)
- 要素が enabled(disabled でない)
従来の Selenium 系で必要だった明示的 WebDriverWait の多くが、Playwright では Locator API に組み込まれています。これを理解せず page.waitForTimeout(2000) で「なんとなく待つ」コードを書くと、環境差でフレーキーが発生します。
Locator vs 生のセレクタ
// ❌ フレーキーの温床:DOM がまだ無い・覆われている・disabled のまま click する
await page.click('#submit-button');
// ✅ Playwright 推奨:auto-wait + 自動リトライ付き assertion
const submitButton = page.getByRole('button', { name: '注文を確定する' });
await expect(submitButton).toBeEnabled();
await submitButton.click();
getByRole・getByLabel・getByTestId など ユーザーが知覚する属性 で要素を特定すると、CSS クラス変更に強く、テスト意図も読みやすくなります。2026年の Playwright ドキュメントも Testing Library と同様の Testing Library 哲学 を推奨しています。
expect による Web-first assertions
expect(locator).toBeVisible() のような Web-first assertion は、条件が真になるまで自動リトライします(デフォルト timeout は 5 秒、設定で変更可能)。
import { test, expect } from '@playwright/test';
test('ダッシュボードに売上グラフが表示される', async ({ page }) => {
await page.goto('/dashboard');
// API レスポンス後にグラフコンテナが描画されるまで待つ
const chart = page.getByTestId('sales-chart');
await expect(chart).toBeVisible({ timeout: 15_000 });
// テキスト内容もリトライ付きで検証
await expect(page.getByRole('heading', { level: 2 })).toHaveText('今月の売上');
});
固定 sleep との決定的な違いは、条件が早く満たされれば即座に次へ進む 点です。CI が遅い日でも必要なだけ待ち、速い日は無駄な待ち時間がありません。
@typescript-eslint/no-restricted-syntax やカスタム ESLint ルールで page.waitForTimeout の使用を PR でブロックするチームが増えています。どうしてもイベントが無いレガシー UI だけ、コメント付きで例外許可リストに載せる運用が現実的です。
待機の優先順位チェックリスト
フレーキー修正時は、次の順で待機手段を検討してください。
getByRole / getByLabel 等の Locator で要素を特定する
expect(locator).toBeVisible() / toHaveText() 等で UI 状態を待つ
page.waitForResponse() で特定 API の完了を待つ(ネットワークモックと併用可)
page.waitForLoadState('networkidle') は SPA では乱れやすいので慎重に
page.waitForTimeout() は最後の手段(原則禁止)
playwright.config.ts:2026年推奨の完全設定
プロジェクトルートの playwright.config.ts は、フレーキー対策の 司令塔 です。以下は TypeScript 完全例です(省略なし)。
import { defineConfig, devices } from '@playwright/test';
import path from 'node:path';
const CI = !!process.env.CI;
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:4173';
export default defineConfig({
testDir: path.join(__dirname, 'e2e'),
fullyParallel: true,
forbidOnly: CI,
retries: CI ? 2 : 0,
workers: CI ? 4 : undefined,
timeout: 60_000,
expect: {
timeout: 10_000,
},
reporter: CI
? [
['github'],
['html', { open: 'never', outputFolder: 'playwright-report' }],
['json', { outputFile: 'test-results/results.json' }],
['junit', { outputFile: 'test-results/junit.xml' }],
]
: [['list'], ['html', { open: 'on-failure' }]],
use: {
baseURL: BASE_URL,
trace: CI ? 'on-first-retry' : 'retain-on-failure',
screenshot: 'only-on-failure',
video: CI ? 'on-first-retry' : 'off',
actionTimeout: 15_000,
navigationTimeout: 30_000,
locale: 'ja-JP',
timezoneId: 'Asia/Tokyo',
extraHTTPHeaders: {
'Accept-Language': 'ja',
},
},
globalSetup: path.join(__dirname, 'e2e/global-setup.ts'),
globalTeardown: path.join(__dirname, 'e2e/global-teardown.ts'),
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'e2e/.auth/user.json',
},
dependencies: ['setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: 'e2e/.auth/user.json',
},
dependencies: ['setup'],
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
storageState: 'e2e/.auth/user.json',
},
dependencies: ['setup'],
},
{
name: 'mobile-chrome',
use: {
...devices['Pixel 7'],
storageState: 'e2e/.auth/user-mobile.json',
},
dependencies: ['setup'],
},
],
webServer: {
command: CI ? 'npm run preview' : 'npm run dev',
url: BASE_URL,
reuseExistingServer: !CI,
timeout: 120_000,
stdout: 'pipe',
stderr: 'pipe',
},
});
設定項目の読み解き
| 設定 | フレーキー対策での役割 |
|---|---|
retries: CI ? 2 : 0 | ローカルは即失敗で原因調査、CI は一時障害を吸収 |
trace: 'on-first-retry' | リトライ1回目でトレース取得。ストレージ節約 |
fullyParallel: true | 並列化の恩恵。ただし テスト分離が必須 |
webServer | サーバー起動待ちを Playwright に任せ、race condition を減らす |
actionTimeout / navigationTimeout | 無限待ちを防ぎ、失敗を早く確定させる |
waitForLoadState('networkidle') はバックグラウンド polling や Analytics ビーコンがある SPA では いつまでも idle にならず フレーキーの原因になります。特定 UI または waitForResponse で待つのが安全です。
テスト分離:並列実行の前提条件
fullyParallel: true を有効にすると、テストファイル内のケースすら並列実行されます。共有状態 があるとフレーキーは確実に増えます。
分離すべき状態一覧
- 認証セッション — Cookie / localStorage / IndexedDB
- サーバー側データ — 同じユーザーIDに対する同時 POST
- 時刻依存 —
Date.now()固定(page.addInitScriptでモック) - Feature Flag — LaunchDarkly 等の共有フラグ
- ファイルシステム — アップロードテストの同名ファイル競合
globalSetup と auth setup プロジェクト
認証状態を毎テスト login 画面から作ると遅く、並列時にレートリミットで落ちることがあります。Playwright 公式パターンの setup project + storageState を使います。
// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'node:path';
const authFile = path.join(__dirname, '.auth/user.json');
setup('authenticate as standard user', async ({ page, request }) => {
// API でテストユーザーを確実に作成(冪等)
const email = `e2e-user-${process.env.TEST_WORKER_INDEX ?? '0'}@example.test`;
const password = process.env.E2E_USER_PASSWORD ?? 'TestPassword123!';
const registerResponse = await request.post('/api/v1/test/seed-user', {
data: {
email,
password,
displayName: 'E2E Standard User',
},
});
if (registerResponse.status() === 409) {
// 既存ユーザー — 問題なし
} else {
expect(registerResponse.ok()).toBeTruthy();
}
await page.goto('/login');
await page.getByLabel('メールアドレス').fill(email);
await page.getByLabel('パスワード').fill(password);
await page.getByRole('button', { name: 'ログイン' }).click();
await expect(page.getByTestId('user-menu')).toBeVisible();
await page.context().storageState({ path: authFile });
});
// e2e/global-setup.ts
import type { FullConfig } from '@playwright/test';
async function globalSetup(config: FullConfig): Promise<void> {
const baseURL = config.projects[0].use.baseURL ?? 'http://127.0.0.1:4173';
const healthCheck = await fetch(`${baseURL}/api/health`);
if (!healthCheck.ok) {
throw new Error(`Application not ready: ${healthCheck.status} ${baseURL}/api/health`);
}
// テスト用 DB マイグレーション(CI コンテナ内のみ)
if (process.env.CI) {
const migrate = await fetch(`${baseURL}/api/test/migrate`, { method: 'POST' });
if (!migrate.ok) {
throw new Error(`Test DB migration failed: ${migrate.status}`);
}
}
console.log('[global-setup] Application ready for E2E');
}
export default globalSetup;
// e2e/global-teardown.ts
import type { FullConfig } from '@playwright/test';
async function globalTeardown(config: FullConfig): Promise<void> {
const baseURL = config.projects[0].use.baseURL ?? 'http://127.0.0.1:4173';
if (process.env.CI) {
const cleanup = await fetch(`${baseURL}/api/test/cleanup`, { method: 'POST' });
if (!cleanup.ok) {
console.warn(`[global-teardown] cleanup returned ${cleanup.status}`);
}
}
console.log('[global-teardown] E2E teardown complete');
}
export default globalTeardown;
Worker-scoped fixture でデータを分離
並列 worker ごとにユニークなリソース名を渡す fixture パターンです。
// e2e/fixtures/isolated-user.ts
import { test as base, expect } from '@playwright/test';
type IsolatedUserFixtures = {
isolatedUser: {
email: string;
password: string;
projectName: string;
};
};
export const test = base.extend<IsolatedUserFixtures>({
isolatedUser: async ({ request }, use, testInfo) => {
const workerIndex = testInfo.parallelIndex;
const timestamp = Date.now();
const email = `worker-${workerIndex}-${timestamp}@example.test`;
const password = 'IsolatedTestPass456!';
const projectName = `proj-w${workerIndex}-${timestamp}`;
const response = await request.post('/api/v1/projects', {
data: { name: projectName, ownerEmail: email },
});
expect(response.ok()).toBeTruthy();
await use({ email, password, projectName });
// teardown: プロジェクト削除(失敗しても他テストに影響しないよう try)
await request.delete(`/api/v1/projects/${encodeURIComponent(projectName)}`).catch(() => {
/* cleanup best-effort */
});
},
});
export { expect } from '@playwright/test';
// e2e/projects/create-project.spec.ts
import { test, expect } from '../fixtures/isolated-user';
test('新規プロジェクトを作成できる', async ({ page, isolatedUser }) => {
await page.goto('/login');
await page.getByLabel('メールアドレス').fill(isolatedUser.email);
await page.getByLabel('パスワード').fill(isolatedUser.password);
await page.getByRole('button', { name: 'ログイン' }).click();
await page.goto('/projects/new');
await page.getByLabel('プロジェクト名').fill(isolatedUser.projectName);
await page.getByRole('button', { name: '作成' }).click();
await expect(page.getByRole('heading', { level: 1 })).toHaveText(isolatedUser.projectName);
});
分離したユーザーで API が 422 / 409 を返すケースは、フロントのエラー表示が APIエラーハンドリング設計 に沿っているかも E2E で確認すると、本番障害時の UX 劣化を防げます。
Trace Viewer:フレーキー調査の最強ツール
Trace Viewer は、テスト実行の タイムマシン です。各アクション前後の DOM スナップショット、ネットワークログ、コンソール、ソースマップ付きスタックが1つの .zip に記録されます。
トレースの有効化と閲覧
# 失敗テストのトレースを表示
npx playwright show-trace test-results/artifacts/.../trace.zip
# 最新レポートから開く
npx playwright show-report
CI では GitHub Actions のアーティファクトとして playwright-report/ と test-results/ をアップロードします。
フレーキー調査ワークフロー
CI ログで flaky だったテスト名とリトライ回数を特定する
on-first-retry で保存された trace.zip をダウンロードする
Timeline で click 直前の DOM を確認 — ボタンが disabled / 覆われていないか
Network タブで API latency とレスポンス body を確認
Console に CORS / 422 エラーが出ていないか確認する
原因が wait 不足なら expect 追加、状態汚染なら fixture 分離、外部障害なら route モック
デバッグ用 trace のローカル設定
// playwright.debug.config.ts — ローカルフレーキー再現専用
import { defineConfig } from '@playwright/test';
import baseConfig from './playwright.config';
export default defineConfig({
...baseConfig,
retries: 0,
workers: 1,
use: {
...baseConfig.use,
trace: 'on',
video: 'on',
launchOptions: {
slowMo: 100,
},
},
});
npx playwright test e2e/checkout/flaky-spec.ts --config=playwright.debug.config.ts
1 worker・slowMo・常時 trace で 競合を排除した再現 ができます。再現したら設定を戻し、並列環境でも通る修正(分離・モック・assert 強化)を入れます。
リトライ設定:深層防御とメトリクス
retries は 治療 ではなく 包帯 です。使い方を間違えると、フレーキーが本番まで届く穴になります。
CI 向け GitHub Actions 完全例
// .github/workflows/e2e.yml 内の jobs 定義(YAML ではなく actions 内スクリプト用 TS ヘルパー例)
// scripts/run-e2e-ci.ts
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
interface PlaywrightJsonReport {
suites: Array<{
specs: Array<{
title: string;
tests: Array<{
results: Array<{
retry: number;
status: 'passed' | 'failed' | 'timedOut' | 'skipped';
}>;
}>;
}>;
}>;
}
function main(): void {
const shardIndex = process.env.SHARD_INDEX ?? '1';
const shardTotal = process.env.SHARD_TOTAL ?? '1';
const result = spawnSync(
'npx',
[
'playwright',
'test',
`--shard=${shardIndex}/${shardTotal}`,
'--reporter=json,html,junit',
],
{
stdio: 'inherit',
env: {
...process.env,
CI: 'true',
},
},
);
const reportPath = path.join(process.cwd(), 'test-results/results.json');
if (fs.existsSync(reportPath)) {
const report = JSON.parse(fs.readFileSync(reportPath, 'utf-8')) as PlaywrightJsonReport;
let totalTests = 0;
let retriedPassed = 0;
for (const suite of report.suites) {
for (const spec of suite.specs) {
for (const test of spec.tests) {
totalTests += 1;
const last = test.results.at(-1);
const hadRetry = test.results.some((r) => r.retry > 0);
if (hadRetry && last?.status === 'passed') {
retriedPassed += 1;
console.log(`[flaky-recovered] ${spec.title}`);
}
}
}
}
const flakyRate = totalTests > 0 ? (retriedPassed / totalTests) * 100 : 0;
console.log(`Flaky recovery rate: ${flakyRate.toFixed(2)}% (${retriedPassed}/${totalTests})`);
// 閾値超えで warn(ジョブは成功のまま — 別ダッシュボードで可視化も可)
if (flakyRate > 2) {
console.warn('::warning::E2E flaky rate exceeds 2%. Review Trace artifacts.');
}
}
process.exit(result.status ?? 1);
}
main();
リトライ運用のルール例
| ルール | 内容 |
|---|---|
ローカル retries: 0 | 開発者は即座に失敗を見て Trace を開く |
CI retries: 2 | コンテナ起動・DB seed の一時遅延を吸収 |
| flaky rate > 2% | スプリント内に修正 Issue を必須起票 |
| 3スプリント未修正 | test.fixme() で quarantine し、カバレッジレポートに明示 |
| merge ブロック | Required Check は 最終結果 の pass/fail のみ |
ネットワークモック:外部依存を決定的にする
E2E から本番 Stripe・Google Maps・社内別チーム API を叩くと、ネットワーク・レートリミット・データ変動 がフレーキーの温床になります。Playwright の page.route() / context.route() で HTTP をインターセプトし、route.fulfill() で固定レスポンスを返します。
REST API のモック完全例
// e2e/mocks/handlers/orders.ts
import type { Page, Route } from '@playwright/test';
export interface MockOrder {
id: string;
status: 'pending' | 'paid' | 'shipped';
totalAmount: number;
currency: 'JPY';
}
const defaultOrders: MockOrder[] = [
{ id: 'ord_001', status: 'paid', totalAmount: 9800, currency: 'JPY' },
{ id: 'ord_002', status: 'pending', totalAmount: 1200, currency: 'JPY' },
];
export async function mockOrdersApi(page: Page, orders: MockOrder[] = defaultOrders): Promise<void> {
await page.route('**/api/v1/orders', async (route: Route) => {
const method = route.request().method();
if (method === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
headers: {
'Cache-Control': 'no-store',
'X-Request-Id': 'e2e-mock-get-orders',
},
body: JSON.stringify({
data: orders,
meta: { total: orders.length, page: 1, perPage: 20 },
}),
});
return;
}
if (method === 'POST') {
const postBody = route.request().postDataJSON() as { productId: string; quantity: number };
const created: MockOrder = {
id: `ord_${Date.now()}`,
status: 'pending',
totalAmount: postBody.quantity * 1000,
currency: 'JPY',
};
await route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ data: created }),
});
return;
}
await route.continue();
});
}
エラーレスポンスのモック(Problem Details)
APIエラーハンドリング設計 で定義した application/problem+json 形式を E2E でも再現し、UI のエラー分岐を安定させます。
// e2e/mocks/handlers/problem-details.ts
import type { Page, Route } from '@playwright/test';
export async function mockInsufficientStock(page: Page): Promise<void> {
await page.route('**/api/v1/checkout', async (route: Route) => {
if (route.request().method() !== 'POST') {
await route.continue();
return;
}
await route.fulfill({
status: 422,
contentType: 'application/problem+json',
body: JSON.stringify({
type: 'https://api.example.com/problems/insufficient-stock',
title: 'Insufficient Stock',
status: 422,
detail: '商品 SKU-123 の在庫が不足しています。',
instance: '/requests/e2e-req-insufficient-stock',
error_code: 'INSUFFICIENT_STOCK',
remaining: 0,
}),
});
});
}
// e2e/checkout/insufficient-stock.spec.ts
import { test, expect } from '@playwright/test';
import { mockInsufficientStock } from '../mocks/handlers/problem-details';
test.beforeEach(async ({ page }) => {
await mockInsufficientStock(page);
});
test('在庫不足時にエラーバナーと error_code が表示される', async ({ page }) => {
await page.goto('/cart');
await page.getByRole('button', { name: 'レジに進む' }).click();
const alert = page.getByRole('alert');
await expect(alert).toBeVisible();
await expect(alert).toContainText('在庫が不足');
await expect(page.getByTestId('error-code')).toHaveText('INSUFFICIENT_STOCK');
});
GraphQL と WebSocket のモック
// e2e/mocks/handlers/graphql-ws.ts
import type { Page } from '@playwright/test';
export async function mockGraphQL(page: Page): Promise<void> {
await page.route('**/graphql', async (route) => {
const postData = route.request().postDataJSON() as {
operationName: string;
variables: Record<string, unknown>;
};
if (postData.operationName === 'NotificationsFeed') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: {
notifications: [
{ id: 'n1', message: 'E2E mock notification', read: false },
],
},
}),
});
return;
}
await route.continue();
});
}
export async function mockNotificationWebSocket(page: Page): Promise<void> {
await page.addInitScript(() => {
class MockWebSocket extends EventTarget {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readyState = MockWebSocket.OPEN;
url: string;
constructor(url: string) {
super();
this.url = url;
queueMicrotask(() => {
this.dispatchEvent(new MessageEvent('message', {
data: JSON.stringify({ type: 'notification', payload: { id: 'ws-1', text: 'mock' } }),
}));
});
}
send(_data: string): void { /* noop */ }
close(): void { this.readyState = MockWebSocket.CLOSED; }
}
(window as unknown as { WebSocket: typeof MockWebSocket }).WebSocket = MockWebSocket;
});
}
HAR 再生と routeFromHAR
記録済み HAR から再現する方法は、クイックにモック基盤を作る際に有効です。
import { test } from '@playwright/test';
test.describe('HAR ベースの決定的テスト', () => {
test.beforeEach(async ({ context }) => {
await context.routeFromHAR('e2e/hars/checkout-flow.har', {
url: '**/api/**',
update: false,
});
});
test('チェックアウトフローが HAR どおり動作する', async ({ page }) => {
await page.goto('/checkout');
await page.getByRole('button', { name: '支払う' }).click();
await page.getByText('ご注文ありがとうございます').waitFor();
});
});
update: true で HAR を再記録するメンテナンスモードを決めておくと、API 変更時の更新フローが明確になります。
フレーキーになりやすいパターンと修正レシピ
アニメーション・スケルトン UI
スケルトンから実コンテンツへの切り替え中に assert すると落ちます。
// ❌ スケルトン表示中に text を検証
await expect(page.getByTestId('product-title')).toHaveText('商品A');
// ✅ スケルトンが消えるまで待ってから検証
await expect(page.getByTestId('product-skeleton')).toBeHidden();
await expect(page.getByTestId('product-title')).toHaveText('商品A');
楽観的 UI 更新
React Query 等の楽観的更新は一瞬古い DOM が残ります。toHaveText より 最終状態の一意な marker を待つのが安全です。
await page.getByRole('button', { name: '保存' }).click();
await expect(page.getByText('保存しました')).toBeVisible();
await expect(page.getByTestId('save-status')).toHaveAttribute('data-state', 'saved');
日付・タイムゾーン
CI は UTC、ローカルは JST だと「今日の日付」assert が落ちます。
await page.addInitScript(() => {
const fixed = new Date('2026-06-22T12:00:00+09:00');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Date as any).now = () => fixed.getTime();
});
ファイルダウンロード
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'CSVをダウンロード' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/orders-2026-06-22\.csv/);
CI/CD統合:shard・キャッシュ・アーティファクト
2026年の中規模以上のリポジトリでは、E2E を shard 分割 し、Browser キャッシュを GitHub Actions Cache に載せます。
# .github/workflows/e2e.yml(抜粋 — 構造参考)
name: E2E
on:
pull_request:
push:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Run Playwright
run: npx tsx scripts/run-e2e-ci.ts
env:
CI: true
SHARD_INDEX: ${{ matrix.shard }}
SHARD_TOTAL: 4
PLAYWRIGHT_BASE_URL: http://127.0.0.1:4173
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report-shard-${{ matrix.shard }}
path: |
playwright-report/
test-results/
retention-days: 14
キャッシュ戦略の要点:
- ブラウザバイナリ —
~/.cache/ms-playwrightを actions/cache で key 固定 - 認証 storageState — setup project の出力を artifact 経由で shard 間共有も可(短命トークンに注意)
- webServer — ビルド成果物
dist/を job 間で再利用
Quarantine と test tagging 戦略
フレーキーが直せない間は @flaky タグで quarantine し、merge 必須スイートから除外します。
// e2e/smoke/critical-path.spec.ts
import { test, expect } from '@playwright/test';
test('トップページが表示される @smoke', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});
// 調査中 — main ブランチ nightly のみ実行
test.fixme('決済フロー @flaky @checkout', async ({ page }) => {
await page.goto('/checkout');
// 調査中のため skip
});
// playwright.smoke.config.ts — PR 用スモークスイート専用(完全設定)
import { defineConfig, devices } from '@playwright/test';
import path from 'node:path';
import baseConfig from './playwright.config';
const CI = !!process.env.CI;
export default defineConfig({
...baseConfig,
testDir: path.join(__dirname, 'e2e'),
retries: 0,
workers: CI ? 2 : 1,
reporter: [['list'], ['github']],
projects: [
{
name: 'smoke-chromium',
grep: /@smoke/,
use: {
...devices['Desktop Chrome'],
storageState: 'e2e/.auth/user.json',
},
},
],
});
// playwright.nightly.config.ts — 夜間フルスイート(@flaky 除外)
import { defineConfig } from '@playwright/test';
import baseConfig from './playwright.config';
const CI = !!process.env.CI;
export default defineConfig({
...baseConfig,
retries: CI ? 2 : 0,
grepInvert: /@flaky/,
reporter: CI
? [
['github'],
['html', { open: 'never', outputFolder: 'playwright-report-nightly' }],
['json', { outputFile: 'test-results/nightly-results.json' }],
]
: [['list'], ['html', { open: 'on-failure' }]],
});
PR では npx playwright test --config=playwright.smoke.config.ts で @smoke のみ、夜間は playwright.nightly.config.ts で @flaky 除外のフルスイート——という リスクベース実行 がフレーキーと開発速度のバランスを取ります。
まとめ:2026年 Playwright E2E 安定化チェックリスト
playwright.config.ts: trace/screenshot/video/retries/webServer を環境別に設定
waitForTimeout を排除し Locator + Web-first assertions に統一
setup project + storageState + worker fixture で状態を分離
外部 API は route.fulfill / HAR / WebSocket モックで決定的に
失敗時 Trace Viewer で DOM・Network・Console を確認
CI flaky rate を計測し 2% 超えたテストは修正 or quarantine
エラー UI は RFC 9457 形式([APIエラーハンドリング設計](/blog/api-error-handling-設計-ベストプラクティス/))で E2E 検証
フレーキー E2E は「運が悪い日がある」ものではなく、待機・分離・依存・観測 の設計不足が表面化した信号です。Playwright の auto-waiting を信頼し、固定 sleep をやめ、Trace Viewer で原因を可視化し、ネットワークをモックして決定的にすれば、CI の信頼性は目に見えて回復します。
E2E は万能ではありません。契約テスト・統合テスト・ユニットテストと テストピラミッド を組み合わせ、E2E では「ユーザーが本当に困るフロー」に资源を集中させる——それが 2026 年のソフトウェア品質 engineering の実務的な落とし所です。
関連記事
あわせて読むと理解が深まる関連記事をまとめました。