Writing a Custom Playwright Reporter
Sooner or later a team needs a result feed that no built-in reporter emits: a row per test in a warehouse table, a Slack digest that names the three slowest specs, an ownership lookup that routes each failure to the team that owns the file, or a gate that fails the pipeline when the flake rate crosses a threshold. Playwright exposes the same Reporter interface its own list, html, json, and junit implementations use, so a custom reporter is a single class with a handful of lifecycle methods. The interface is small, but three details — where the reporter runs, which hooks are awaited, and how retries are represented — cause almost every broken reporter in the wild. This page builds a correct one and explains each of those traps.
Root cause: built-in reporters answer a fixed set of questions
The reporters shipped with @playwright/test serialize a fixed schema — a status, a duration, an error, a list of attachments — because they have to work for every project. Anything your organization cares about that is not in that schema (the owning team, the environment name, the deploy SHA, a cost-per-minute figure, a per-step latency budget) has nowhere to live, and post-processing results.json after the fact loses timing context and any option values the run was configured with. A reporter closes that gap by observing the run from inside the test runner's own process while it happens. Everything here builds on Reporters & Test Artifacts, part of Debugging & Test Observability.
The lifecycle you are hooking into
A reporter is a class with optional methods. Playwright calls onBegin() once with the resolved configuration and the root suite, then onTestBegin(), a pair of onStepBegin() / onStepEnd() calls per step, and onTestEnd() for every test attempt. When the run finishes it calls onEnd() with a FullResult, and finally onExit() immediately before the process terminates. Two of those hooks — onEnd() and onExit() — are awaited if they return a promise. The per-test hooks are not.
Minimal reproducible example
Here is the reporter almost everyone writes first. It looks reasonable and produces a file that is short, incomplete, and wrong about flaky tests.
import type { Reporter, TestCase, TestResult } from '@playwright/test/reporter';
import { appendFile } from 'node:fs/promises';
// A first-attempt reporter with two defects, both invisible locally.
export default class NaiveReporter implements Reporter {
private failures = 0;
async onTestEnd(test: TestCase, result: TestResult) {
// DEFECT 1: onTestEnd's return value is NOT awaited by the runner.
// Under load the process can exit with these appends still in flight,
// so the file ends up truncated — and often empty on fast suites.
await appendFile('results.ndjson', JSON.stringify({
title: test.title,
status: result.status, // DEFECT 2: this is ONE attempt, not the verdict
ms: result.duration,
}) + '\n');
// With retries: 2, a test that fails twice then passes increments this
// twice, so the "failure" count reports a flake as two hard failures.
if (result.status === 'failed') this.failures++;
}
}
Both defects are silent on a laptop running a green suite with retries: 0. They appear the first time the reporter meets a real CI run with retries enabled and several workers.
Where reporter code actually runs
Test bodies run inside worker processes; the reporter runs in the main process that spawned them. Every TestCase and TestResult you receive has crossed a process boundary as structured data, which is why a reporter can never touch page, a Locator, or any fixture — those objects only exist in the worker. It also means results arrive interleaved from every worker, in completion order rather than declaration order, so a reporter that assumes file-by-file sequencing will scramble its own grouping.
One reporter instance is constructed for the whole run, which makes instance fields a safe place to accumulate state — there is no second copy racing you, and no locking to think about. The structures you receive are still rich: TestCase carries title, titlePath(), location, annotations, timeout, retries, and expectedStatus, while TestResult carries duration, startTime, retry, workerIndex, parallelIndex, errors, stdout, stderr, attachments, and the step tree. suite.allTests() in onBegin() gives you the planned population before a single test runs, which is what lets a reporter report on tests that never executed because the run was interrupted. Anything the worker knows but does not serialize — a browser-side timing, a request header, an application log line — has to be attached deliberately from inside the test.
How retries are represented
A test that is retried produces one TestResult per attempt, all hanging off the same TestCase. result.status describes a single attempt, while test.outcome() collapses the whole set into one of four verdicts by comparing each attempt against test.expectedStatus. Any counter built on the former inflates every flake into multiple failures.
Step-by-step fix
-
Default-export a class implementing
Reporter. Playwright constructs the module's default export, so a named-only export leaves it with a value that is not a constructor and the run aborts before the first test. Import the types from@playwright/test/reporter, not from@playwright/test.import type { Reporter, FullConfig, Suite } from '@playwright/test/reporter'; export type ReportOptions = { outputFile?: string; env?: string }; export default class MetricsReporter implements Reporter { private readonly outputFile: string; private readonly env: string; private startedAt = 0; private projectCount = 0; private plannedTests = 0; // The second element of the reporter tuple in the config is passed here. constructor(options: ReportOptions = {}) { this.outputFile = options.outputFile ?? 'metrics.json'; this.env = options.env ?? process.env.TEST_ENV ?? 'local'; } onBegin(config: FullConfig, suite: Suite) { this.startedAt = Date.now(); this.projectCount = config.projects.length; // allTests() flattens the whole tree, so this is the real planned total. this.plannedTests = suite.allTests().length; } } -
Register the module in
playwright.config.tsand pass its options. The path is resolved relative to the configuration file's directory, and Playwright transpiles a TypeScript reporter with the same loader it uses for specs, so no build step is needed. Keep a terminal reporter alongside it — see Playwright Config & Fixtures for how the rest of the file is organized.import { defineConfig } from '@playwright/test'; export default defineConfig({ retries: process.env.CI ? 2 : 0, reporter: [ ['list'], // live terminal feedback ['html', { open: 'never' }], // forensic report for humans ['./reporters/metrics-reporter.ts', { // path is relative to this file outputFile: 'metrics.json', env: process.env.TEST_ENV ?? 'ci', }], ], }); -
Buffer facts in
onTestEndand never await I/O there. Push a plain object onto an array. The hook is called once per attempt, so recordresult.retryand keep every attempt rather than overwriting. -
Classify with
test.outcome(), notresult.status.result.statusis'passed' | 'failed' | 'timedOut' | 'skipped' | 'interrupted'for one attempt;test.outcome()returns'expected' | 'unexpected' | 'flaky' | 'skipped'after weighing every attempt againsttest.expectedStatus. Only emit the verdict on the final attempt, which you can detect withresult.retry === test.retriesor, more robustly, by keying the summary ontest.id. This distinction is the whole basis of Detecting and Fixing Flaky Playwright Tests. -
Read attachments and step timings from the result object.
result.attachmentsis an array of{ name, contentType, path, body }, which is where a trace, a video, or anything added withtestInfo.attach()shows up — the same artifacts described in Capturing Screenshots and Video on Test Failure.result.stepsis a tree; recurse it and filter onstep.categoryto separate'expect'assertions from'pw:api'calls. -
Flush in
onEnd()and ship inonExit().onEnd()receives aFullResultwhosestatusis'passed','failed','timedout', or'interrupted', and it may return{ status: 'failed' }to override the run's verdict — the hook that lets a reporter enforce a flake budget. Do local file writes here; do slow network uploads inonExit(), which runs after every reporter has finished.import type { Reporter, TestCase, TestResult, FullResult, TestStep, } from '@playwright/test/reporter'; import { writeFile } from 'node:fs/promises'; type Row = { id: string; title: string; project: string; file: string; retry: number; status: string; outcome: string; ms: number; asserts: number; attachments: string[]; error?: string; }; export default class MetricsReporter implements Reporter { private rows: Row[] = []; // Synchronous and cheap: this hook's promise is never awaited. onTestEnd(test: TestCase, result: TestResult) { this.rows.push({ id: test.id, title: test.titlePath().slice(3).join(' > '), // drop root/project/file project: test.parent.project()?.name ?? 'default', file: test.location.file, retry: result.retry, // 0 on the first attempt status: result.status, // this attempt only outcome: test.outcome(), // verdict across all attempts ms: result.duration, asserts: this.countSteps(result.steps, 'expect'), attachments: result.attachments.map((a) => a.name), // errors[] holds every failure; message is already ANSI-stripped enough error: result.errors[0]?.message?.split('\n')[0], }); } // result.steps is a tree, so recurse rather than reading the top level. private countSteps(steps: TestStep[], category: string): number { return steps.reduce( (n, s) => n + (s.category === category ? 1 : 0) + this.countSteps(s.steps, category), 0, ); } // Awaited by the runner — safe place for disk writes. async onEnd(result: FullResult) { const finals = this.rows.filter((r) => r.outcome !== 'skipped'); const flaky = new Set( finals.filter((r) => r.outcome === 'flaky').map((r) => r.id), ).size; await writeFile( 'metrics.json', JSON.stringify({ status: result.status, flaky, rows: this.rows }, null, 2), ); // Enforce a budget: more than five flaky tests fails the run. if (flaky > 5) return { status: 'failed' as const }; } // Runs after every reporter has ended: the place for slow uploads. async onExit() { if (!process.env.METRICS_ENDPOINT) return; await fetch(process.env.METRICS_ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(this.rows), }); } } -
Declare
printsToStdio()and handle run-level errors. A reporter that writes only to a file should returnfalseso Playwright knows it must still show progress; returning the defaulttruewhile printing nothing leaves a silent terminal for the whole run. AddonError()to catch failures that belong to no test, such as a crash in global setup.import type { Reporter, TestError } from '@playwright/test/reporter'; export default class QuietReporter implements Reporter { private globalErrors: string[] = []; // false => Playwright keeps its own minimal progress output visible. printsToStdio() { return false; } // Fires for errors not attributable to any single test. onError(error: TestError) { this.globalErrors.push(error.message ?? String(error)); } }
Troubleshooting variants
The run aborts with a module-resolution error naming your reporter
Playwright resolves a reporter path against the directory holding playwright.config.ts, not the current working directory, so running from a monorepo root with a relative path such as './reporters/metrics-reporter.ts' fails with a Cannot find module error that prints the absolute path it tried. Fix it by keeping the path relative to the config file, or resolve it explicitly with path.join(__dirname, 'reporters/metrics-reporter.ts'). A near-identical symptom with a different cause is a module that exports the class by name only — Playwright reads the default export and reports that the value is not a constructor.
The output file is empty, truncated, or missing the last few tests
This is the first defect from the example above. Playwright does not await the promise returned by onTestEnd(), onTestBegin(), or the step hooks, so any await inside them races the process exit and loses whatever was still in flight. Collect into memory in those hooks and perform every write inside onEnd() or onExit(). The same rule applies to appends: opening a stream in onBegin() still requires an explicit await on the close in onEnd(), otherwise buffered bytes are dropped.
Numbers disagree with the HTML report when retries are on
A suite configured with retries: 2 produces up to three onTestEnd calls for one test, and a reporter that increments a counter on result.status === 'failed' reports two failures where the HTML report shows one flaky test. Deduplicate on test.id and read test.outcome() for the verdict, then treat retries as the tuning knob they are, as covered in Configuring Retries and Timeouts for Stable CI and Quarantining Flaky Tests Without Blocking CI. Sharded runs need one more step: point each shard at the blob reporter, then run npx playwright merge-reports --reporter=./reporters/metrics-reporter.ts ./blob-report so your reporter replays the merged event stream once instead of firing per shard, the pattern used in Running Playwright Tests in GitHub Actions with Sharding.
Verification
Validate the reporter against a deliberately messy fixture suite rather than a green one. Write four tests — one that passes, one that fails every time, one that fails once and then passes, and one marked test.skip() — run them with --retries=2 --workers=2, and assert that the emitted file contains exactly one row per attempt, four distinct test.id values, and exactly one row whose outcome is flaky. Cross-check the totals against npx playwright test --reporter=json, whose stats block is an independent implementation of the same arithmetic; if the two disagree, your classification logic is the side that is wrong.
Then verify the shutdown path, since that is where silent data loss lives. Run the suite with roughly a thousand fast tests and confirm the row count in the output file matches the planned count captured in onBegin() — a mismatch means work is still escaping an unawaited hook. Finally, confirm the ordering assumption by running once with --workers=1 and once with --workers=4: the file contents should differ only in row order, never in row count. For a failure the reporter flags but you cannot explain, open the matching artifact through the Playwright Trace Viewer, and treat persistent disagreement between runs as a suite problem to work through Flaky Test Management rather than a reporter bug.
Frequently Asked Questions
Can a custom reporter access the page or a locator?
No. Reporter code executes in Playwright's main process while test bodies execute in worker processes, and every TestCase and TestResult you receive has already been serialized across that boundary. If you need something only the browser knows — a performance entry, a console log, a network timing — capture it inside the test with testInfo.attach() and read it back from result.attachments in the reporter.
Does adding a custom reporter replace the built-in ones?
Only if you replace the whole reporter array in the configuration, or pass --reporter on the command line, which overrides the array entirely. Reporters compose: list your custom entry alongside list and html and all three receive the same events. Passing --reporter=./my-reporter.ts for a one-off run silently drops the HTML report, which surprises people who then cannot find it afterwards.
How do I pass configuration into a reporter?
Register it as a two-element tuple where the second element is an options object; Playwright passes that object to your constructor as its only argument. Give every option a default so the reporter still works when someone registers it with the bare path, and read secrets from process.env inside the constructor rather than committing them into the configuration file.
What happens if my reporter throws?
An exception escaping a hook is surfaced as a reporter error and can take the run down with it, discarding results that were otherwise complete. Wrap anything that touches the network or the filesystem in a try/catch, degrade to writing a local file when an upload fails, and never let a reporting concern be the reason a passing suite reports red.