Cách xây dựng Playwright Reporter tùy chỉnh cho Next.js Dashboards

Table of Contents
Xây dựng một reporter tùy chỉnh cho Playwright là cách hiệu quả nhất để truyền trực tiếp kết quả tự động hóa kiểm thử vào bảng điều khiển Next.js của riêng bạn. Thay vì dựa vào các báo cáo HTML tĩnh hoặc trả tiền cho các dịch vụ của bên thứ ba, bạn có thể viết một reporter chuyên dụng để thu thập các số liệu kiểm thử chính xác và đăng chúng trực tiếp lên backend của bạn, cung cấp cho nhóm của bạn một nền tảng phân tích riêng tư, tự lưu trữ mà không bị khóa nhà cung cấp.
Trong hướng dẫn này, bạn sẽ tìm hiểu chính xác cách triển khai một JSON reporter tùy chỉnh trong Playwright, xây dựng một tuyến API nhận trong Next.js, thêm tính năng theo dõi độ không ổn định, hỗ trợ phân chia CI trên nhiều trình chạy và lưu trữ kết quả vào PostgreSQL thông qua Prisma.
Tại sao báo cáo HTML tiêu chuẩn không đáp ứng đủ
Các báo cáo HTML tiêu chuẩn của Playwright chỉ phục vụ một mục đích duy nhất: hiển thị kết quả pass/fail cho một lần chạy kiểm thử duy nhất. Chúng không cung cấp tính năng theo dõi lịch sử, không phát hiện độ không ổn định qua các lần chạy, không có bảng điều khiển toàn nhóm và không tích hợp với các hệ thống cảnh báo của bạn.
Khi bạn có một nhóm chạy hàng nghìn kiểm thử hàng ngày trên nhiều pipeline CI, bạn cần:
- Dữ liệu xu hướng lịch sử — Tính ổn định của kiểm thử có cải thiện sau khi tái cấu trúc không?
- Bảng xếp hạng độ không ổn định — Kiểm thử nào chỉ pass khi thử lại?
- Khả năng hiển thị theo từng nhánh — PR này có gây ra lỗi mới không?
- Truyền trực tiếp theo thời gian thực — Các nhà phát triển có thể xem kết quả khi chúng đang chạy, chứ không phải sau đó không?
Một Playwright reporter tùy chỉnh giải quyết tất cả những vấn đề này bằng cách triển khai giao diện Reporter tích hợp của Playwright và đăng dữ liệu có cấu trúc lên API của riêng bạn.
Triển khai giao diện Playwright Reporter
Playwright cung cấp một giao diện Reporter với các hook vòng đời được kích hoạt vào những thời điểm chính xác trong quá trình chạy kiểm thử. Các hook chính là:
onBegin(config, suite)— được gọi một lần khi quá trình chạy kiểm thử bắt đầuonTestBegin(test, result)— được gọi trước mỗi kiểm thử riêng lẻonTestEnd(test, result)— được gọi sau khi mỗi kiểm thử hoàn thành (pass, fail hoặc skip)onEnd(result)— được gọi một lần khi toàn bộ quá trình chạy kết thúc
Tạo dashboard-reporter.ts trong thư mục gốc dự án Playwright của bạn:
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;
Đăng ký reporter trong 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,
}],
],
});
Xây dựng bộ nhận API Next.js
Tuyến API xác thực payload đến, ghi vào PostgreSQL và trả về ngay lập tức. Sử dụng App Router tại app/api/reports/route.ts:
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 });
}
Prisma Schema để lưu trữ kiểm thử
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])
}
Truy vấn bảng điều khiển độ không ổn định
Với kết quả đã được lưu trữ, bạn có thể tính toán tỷ lệ độ không ổn định trên mỗi kiểm thử trên các nhánh bằng một truy vấn duy nhất:
// 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,
});
Hỗ trợ phân chia CI
Phân chia CI của Playwright chia bộ kiểm thử của bạn trên nhiều trình chạy để tăng tốc độ. Mỗi phân đoạn chạy độc lập và đăng kết quả của nó lên cùng một endpoint. Vì tuyến API sử dụng upsert được khóa trên runId (được đặt qua CI_RUN_ID), tất cả các phân đoạn sẽ hợp nhất thành một bản ghi chạy thống nhất duy nhất:
# .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 }}
Mỗi trong số 4 phân đoạn đăng CI_RUN_ID của nó, và các số đếm tổng hợp tích lũy chính xác trong cơ sở dữ liệu thông qua logic tăng upsert.
Câu hỏi thường gặp
data.failedTests > 0 và data.branch === 'main'. Nếu có, hãy thực hiện một lệnh gọi fetch bổ sung đến webhook của Slack hoặc PagerDuty Events API v2. Điều này cung cấp cho bạn cảnh báo theo thời gian thực mà không cần một bước CI riêng biệt.Bạn cũng có thể thích
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

Các lựa chọn thay thế Playwright tốt nhất cho tự động hóa doanh nghiệp
Playwright cực kỳ mạnh mẽ, nhưng các nhóm doanh nghiệp đôi khi cần các lựa chọn thay thế khi bộ công cụ của họ mở rộng quy mô. Chúng tôi so sánh các công cụ kiểm thử E2E hàng đầu cho năm 2026 dựa trên tích hợp CI/CD, hồi quy hình ảnh và các tính năng AI.
Read more
Tư vấn di chuyển từ Cypress sang Playwright: Nâng cấp chiến lược kiểm thử của bạn
Hướng dẫn từng bước và kế hoạch tư vấn để di chuyển các bộ kiểm thử doanh nghiệp từ Cypress sang Playwright nhằm chạy nhanh hơn và loại bỏ các kiểm thử không ổn định.
Read more
Kiểm thử hồi quy trực quan và hướng dẫn so khớp pixel với Playwright
Hướng dẫn đầy đủ về cấu hình kiểm thử hồi quy trực quan Playwright, ngưỡng so khớp pixel, che lỗi để chống rung và lưu trữ ảnh chụp nhanh đa nền tảng.
Read more