Playwright architecture, selector reliability, and advanced interaction patterns.

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.

Playwright reporter hooks in call order Six reporter lifecycle hooks laid out in call order, from onBegin through the per-test hooks to onEnd and onExit. Reporter hooks, in call order onBegin(config, suite) run starts, suite known onTestBegin(test, res) per test, before body onStepBegin / onStepEnd per step, very chatty onTestEnd(test, res) not awaited by runner onEnd(result) awaited, flush here onExit() awaited, upload here onTestEnd fires once per attempt, retries included
The per-test hooks fire many times and are fire-and-forget; only onEnd and onExit give you an awaited window to write files or call a network API.

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.

Reporter process topology The reporter instance lives in the main Playwright process while tests execute in separate worker processes that send serialized results back. Where reporter code runs Main process Reporter instance buffers results playwright.config.ts reporter array Worker 0 browser, context, page Worker 1 browser, context, page Worker 2 browser, context, page results serialized over IPC, in completion order
Because results cross a process boundary, a reporter sees plain data — never a live page — and receives it interleaved from every worker.

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.

Outcome classification across retries A decision tree showing how test.outcome collapses every attempt into one of four verdicts, versus per-attempt result status. Classifying a test across its attempts test.outcome() expected every attempt passed unexpected failed at the end counts as a bug flaky failed, then passed on retry skipped never ran not a failure result.status describes one attempt only summing it double-counts every retry
Four verdicts collapse an arbitrary number of attempts; counting raw attempt statuses is what makes a flake look like two hard failures.

Step-by-step fix

  1. 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;
      }
    }
  2. Register the module in playwright.config.ts and 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',
        }],
      ],
    });
  3. Buffer facts in onTestEnd and never await I/O there. Push a plain object onto an array. The hook is called once per attempt, so record result.retry and keep every attempt rather than overwriting.

  4. Classify with test.outcome(), not result.status. result.status is 'passed' | 'failed' | 'timedOut' | 'skipped' | 'interrupted' for one attempt; test.outcome() returns 'expected' | 'unexpected' | 'flaky' | 'skipped' after weighing every attempt against test.expectedStatus. Only emit the verdict on the final attempt, which you can detect with result.retry === test.retries or, more robustly, by keying the summary on test.id. This distinction is the whole basis of Detecting and Fixing Flaky Playwright Tests.

  5. Read attachments and step timings from the result object. result.attachments is an array of { name, contentType, path, body }, which is where a trace, a video, or anything added with testInfo.attach() shows up — the same artifacts described in Capturing Screenshots and Video on Test Failure. result.steps is a tree; recurse it and filter on step.category to separate 'expect' assertions from 'pw:api' calls.

  6. Flush in onEnd() and ship in onExit(). onEnd() receives a FullResult whose status is '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 in onExit(), 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),
        });
      }
    }
  7. Declare printsToStdio() and handle run-level errors. A reporter that writes only to a file should return false so Playwright knows it must still show progress; returning the default true while printing nothing leaves a silent terminal for the whole run. Add onError() 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.

Back to overview