
Playwright 用のカスタムレポーターを構築することは、ライブテスト自動化の結果を独自の Next.js ダッシュボードにストリーミングする最も効果的な方法です。静的な HTML レポートに依存したり、サードパーティのサービスに料金を支払ったりする代わりに、正確なテストメトリクスをキャプチャしてバックエンドに直接投稿する専用のレポーターを作成できます。これにより、チームはベンダーロックインなしでプライベートなセルフホスト型分析プラットフォームを利用できます。
このガイドでは、Playwright でカスタム JSON レポーターを実装し、Next.js で受信 API ルートを構築し、不安定性追跡を追加し、複数のランナーにわたる CI シャーディングをサポートし、Prisma 経由で PostgreSQL に結果を永続化する方法を正確に学びます。
標準の Playwright HTML レポートは、単一の目的を果たします。それは、単一のテスト実行の合否を示すことです。履歴追跡、複数実行にわたる不安定性検出、チーム全体のダッシュボード、アラートシステムとの統合は提供されません。
チームが複数の CI パイプラインで毎日何千ものテストを実行している場合、次のものが必要です。
カスタム Playwright レポーターは、Playwright の組み込み Reporter インターフェースを実装し、構造化データを独自の API に投稿することで、これらすべてを解決します。
Playwright は、テスト実行中の正確なタイミングで起動するライフサイクルフックを備えた Reporter インターフェースを公開しています。主なフックは次のとおりです。
onBegin(config, suite): テスト実行開始時に一度呼び出されますonTestBegin(test, result): 個々のテストの前に呼び出されますonTestEnd(test, result): 各テストが完了した後(合格、失敗、スキップ)に呼び出されますonEnd(result): 実行全体が終了したときに一度呼び出されますPlaywright プロジェクトのルートに dashboard-reporter.ts を作成します。
import type {
Reporter,
TestCase,
TestResult,
FullResult,
Suite,
FullConfig,
} from '@playwright/test/reporter';
interface TestPayload {
testId: string;
title: string;
fullTitle: string;
filePath: string;
status: 'passed' | 'failed' | 'flaky' | 'skipped' | 'timedOut';
retryCount: number;
durationMs: number;
workerIndex: number;
errorMessage: string | null;
errorStack: string | null;
attachmentUrls: string[];
}
interface RunPayload {
runId: string;
branch: string;
commitSha: string;
status: FullResult['status'];
startedAt: string;
finishedAt: string;
totalTests: number;
passedTests: number;
failedTests: number;
flakyTests: number;
skippedTests: number;
tests: TestPayload[];
}
class NextjsDashboardReporter implements Reporter {
private results: TestPayload[] = [];
private startedAt: string = '';
private runId: string;
private dashboardUrl: string;
constructor(options: { dashboardUrl?: string } = {}) {
this.runId = process.env.CI_RUN_ID ?? crypto.randomUUID();
this.dashboardUrl =
options.dashboardUrl ??
process.env.DASHBOARD_URL ??
'http://localhost:3000';
}
onBegin(_config: FullConfig, _suite: Suite) {
this.startedAt = new Date().toISOString();
console.log(`[Reporter] Run ${this.runId} started at ${this.startedAt}`);
}
onTestEnd(test: TestCase, result: TestResult) {
// A test that passed but only after retries is flaky
const isFlaky = result.status === 'passed' && result.retry > 0;
this.results.push({
testId: test.id,
title: test.title,
fullTitle: test.titlePath().join(' > '),
filePath: test.location.file,
status: isFlaky ? 'flaky' : result.status,
retryCount: result.retry,
durationMs: result.duration,
workerIndex: result.workerIndex ?? -1,
errorMessage: result.errors[0]?.message?.slice(0, 2000) ?? null,
errorStack: result.errors[0]?.stack?.slice(0, 4000) ?? null,
// Attachments are uploaded to S3/R2 separately; we store only URLs
attachmentUrls: result.attachments
.filter((a) => a.path)
.map((a) => `${this.dashboardUrl}/artifacts/${this.runId}/${a.name}`),
});
}
async onEnd(result: FullResult) {
const payload: RunPayload = {
runId: this.runId,
branch: process.env.GITHUB_REF_NAME ?? 'local',
commitSha: process.env.GITHUB_SHA ?? 'unknown',
status: result.status,
startedAt: this.startedAt,
finishedAt: new Date().toISOString(),
totalTests: this.results.length,
passedTests: this.results.filter((t) => t.status === 'passed').length,
failedTests: this.results.filter((t) => t.status === 'failed').length,
flakyTests: this.results.filter((t) => t.status === 'flaky').length,
skippedTests: this.results.filter((t) => t.status === 'skipped').length,
tests: this.results,
};
try {
const response = await fetch(`${this.dashboardUrl}/api/reports`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-reporter-secret': process.env.REPORTER_SECRET ?? '',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
console.error(`[Reporter] Upload failed: ${response.status} ${await response.text()}`);
} else {
console.log(`[Reporter] Run ${this.runId} uploaded successfully.`);
}
} catch (err) {
// Never let reporter errors crash the CI process
console.error('[Reporter] Network error during upload:', err);
}
}
}
export default NextjsDashboardReporter;
playwright.config.ts にレポーターを登録します。
import { defineConfig } from '@playwright/test';
export default defineConfig({
reporter: [
['list'], // Console output during run
['./dashboard-reporter.ts', { // Custom dashboard reporter
dashboardUrl: process.env.DASHBOARD_URL,
}],
],
});
API ルートは、受信ペイロードを検証し、PostgreSQL に書き込み、すぐに返します。app/api/reports/route.ts の App Router を使用します。
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
const REPORTER_SECRET = process.env.REPORTER_SECRET;
export async function POST(request: Request) {
// Validate the shared secret
if (REPORTER_SECRET) {
const secret = request.headers.get('x-reporter-secret');
if (secret !== REPORTER_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
const data = await request.json();
// Upsert the run record (idempotent for CI sharding where multiple workers post)
const run = await prisma.testRun.upsert({
where: { runId: data.runId },
create: {
runId: data.runId,
branch: data.branch,
commitSha: data.commitSha,
status: data.status,
startedAt: new Date(data.startedAt),
finishedAt: new Date(data.finishedAt),
totalTests: data.totalTests,
passedTests: data.passedTests,
failedTests: data.failedTests,
flakyTests: data.flakyTests,
skippedTests: data.skippedTests,
},
update: {
// Merge shard results: aggregate counts
totalTests: { increment: data.totalTests },
passedTests: { increment: data.passedTests },
failedTests: { increment: data.failedTests },
flakyTests: { increment: data.flakyTests },
skippedTests: { increment: data.skippedTests },
status: data.status === 'failed' ? 'failed' : undefined,
finishedAt: new Date(data.finishedAt),
},
});
// Batch-insert all individual test results
await prisma.testResult.createMany({
data: data.tests.map((t: any) => ({
runId: run.id,
testId: t.testId,
title: t.title,
fullTitle: t.fullTitle,
filePath: t.filePath,
status: t.status,
retryCount: t.retryCount,
durationMs: t.durationMs,
workerIndex: t.workerIndex,
errorMessage: t.errorMessage,
})),
skipDuplicates: true,
});
return NextResponse.json({ success: true, runId: run.runId });
}
model TestRun {
id Int @id @default(autoincrement())
runId String @unique
branch String
commitSha String
status String
startedAt DateTime
finishedAt DateTime
totalTests Int @default(0)
passedTests Int @default(0)
failedTests Int @default(0)
flakyTests Int @default(0)
skippedTests Int @default(0)
results TestResult[]
createdAt DateTime @default(now())
@@index([branch])
@@index([commitSha])
}
model TestResult {
id Int @id @default(autoincrement())
run TestRun @relation(fields: [runId], references: [id])
runId Int
testId String
title String
fullTitle String
filePath String
status String
retryCount Int @default(0)
durationMs Int
workerIndex Int
errorMessage String?
@@index([testId])
@@index([status])
@@index([filePath])
}
結果が永続化されると、単一のクエリでブランチ全体のテストごとの不安定性率を計算できます。
// Top 10 flaky tests in the last 30 days
const flakyLeaderboard = await prisma.testResult.groupBy({
by: ['testId', 'fullTitle', 'filePath'],
where: {
run: { startedAt: { gte: new Date(Date.now() - 30 * 86400_000) } },
},
_count: { testId: true },
_sum: { retryCount: true },
having: { retryCount: { _sum: { gt: 0 } } },
orderBy: { _sum: { retryCount: 'desc' } },
take: 10,
});
Playwright CI シャーディングは、テストスイートを複数のランナーに分割して高速化します。各シャードは独立して実行され、その結果を同じエンドポイントに投稿します。API ルートは CI_RUN_ID を介して設定された runId をキーとする upsert を使用するため、すべてのシャードは単一の統合された実行レコードにマージされます。
# .github/workflows/e2e.yml
strategy:
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
env:
CI_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}
DASHBOARD_URL: ${{ secrets.DASHBOARD_URL }}
REPORTER_SECRET: ${{ secrets.REPORTER_SECRET }}
4 つのシャードのそれぞれが CI_RUN_ID を投稿し、upsert のインクリメントロジックを介して集計カウントがデータベースに正しく蓄積されます。
あるチームは、Playwright E2E スイートで断続的なテスト失敗に悩んでおり、リグレッションの特定が困難になっています。彼らは現在、標準の Playwright HTML レポートに依存しています。カスタム Playwright reporter によって有効になる以下の機能のうち、この問題に対処するのに最も効果的なものはどれですか?
data.failedTests > 0 と data.branch === 'main' を確認します。もしそうであれば、Slack ウェブフックまたは PagerDuty Events API v2 に追加のフェッチ呼び出しを行います。これにより、別の CI ステップなしでリアルタイムアラートが提供されます。AI CLIログの整形、Cron式の作成、JWTの解析、chmod権限の計算をオフラインで実行。

Next.jsでDuckDB-Wasmを使用し、本番環境レベルのアーキテクチャとコード例で1000万行に対する超高速クライアントサイド分析を実現する包括的なガイド。
続きを読む
App RouterでのNext.jsとPrismaの完全ガイドで、グローバルなコネクションプール枯渇の防止、型安全なクエリ、サーバーアクションミューテーション、シードスクリプトについて解説します。
続きを読む
Playwrightのauto-waiting、browser context isolation、network interception、auth storage、CI parallelizationを活用し、E2Eテストを習得するためのガイドです。
続きを読む