Playwright architecture, selector reliability, and advanced interaction patterns.

Reporters & Test Artifacts

A test run produces two kinds of output: the human-readable verdict of what passed and what failed, and the forensic evidence — screenshots, video, and traces — you need to understand a failure you did not witness. Playwright ships several built-in reporters for the first and an artifact system for the second, and choosing the right combination for local development versus CI is what makes a red build actionable instead of a mystery. This guide, part of Debugging & Test Observability, covers every built-in reporter, the screenshot, video, and trace artifacts, the reporter lifecycle those artifacts flow through, and how to wire both into a continuous integration pipeline so every failure arrives with the evidence already attached.

Reporters and artifacts produced by a test run A test run branching into reporter outputs for humans and CI, and artifact outputs of screenshots, video, and traces for forensic debugging. Test run playwright test Reporters (human) list, line, dot, html Reporters (machine) json, junit Artifacts shot, video, trace CI dashboard
One run fans out into human-readable reporters, machine-readable reporters for CI, and forensic artifacts; the CI dashboard consumes the machine output and links to the artifacts.

Why the default output stops being enough

Run npx playwright test on a laptop with ten tests and the terminal tells you everything you need: which test failed, on which line, with the expected and actual values printed inline. You still have the browser, the app, and your own memory of what you just changed, so the gap between "the run is red" and "I know why" is a few seconds wide.

That gap widens fast. On CI the browser is gone before you read the log, the machine is a container you cannot attach to, the app was seeded with data you did not choose, and the failure may be one test out of four thousand spread over eight shards. The stack trace tells you an assertion timed out waiting for a locator; it does not tell you that a cookie banner covered the button, that an XHR returned a 500, or that the element rendered 40 milliseconds after the timeout expired. Reproducing locally is often impossible because the failure depends on timing, shard ordering, or an environment you cannot recreate.

Observability closes that gap by shifting the cost from reproduction to recording. Instead of trying to re-create the failure, you arrange for the run itself to leave behind enough evidence that a failure can be reconstructed after the fact. Two systems do that work and they are frequently confused. Reporters consume the result stream and turn it into output — terminal lines, an HTML site, a JSON tree, an XML file. Artifacts are byte blobs captured by the browser and the test runner during execution — images, WebM video, trace archives, and anything you attach yourself. Reporters mostly do not create artifacts; they reference them. Understanding that split is what stops you from, say, expecting the junit reporter to embed a screenshot, or expecting screenshot: 'only-on-failure' to show up anywhere useful when your only reporter is dot.

The reporter contract

A reporter is an object implementing a set of lifecycle hooks that the test runner calls as results arrive. Everything the built-in reporters do — and everything a custom one can do — flows through that same contract, so it pays to know the order of the calls before you tune configuration.

Playwright runs your tests in a pool of worker processes but instantiates reporters exactly once, in the main process. Worker results are serialised back to the main process and replayed into every configured reporter in registration order. This has three practical consequences. First, reporters see a single ordered stream even though tests ran in parallel, so onTestEnd for test A can arrive between onTestBegin and onTestEnd for test B. Second, a reporter cannot touch page objects or fixtures — by the time it is called, the worker that owned them may already have exited. Third, anything a reporter writes to standard output interleaves with the terminal reporter's own rendering, which is why a reporter that logs carelessly will corrupt the line reporter's cursor tricks.

Reporter lifecycle hook sequence A sequence diagram showing the test runner and worker processes calling reporter hooks in order, ending with report files written to disk. Test runner Worker process Reporter instance onBegin(config, suite) onTestBegin(test, result) onStepBegin / onStepEnd onTestEnd(test, result) onEnd(fullResult) middle three repeat per test and per retry report files written html + junit + traces
Reporters live in the main process and receive a serialised, ordered stream of hook calls; only after onEnd resolves are report files guaranteed to be on disk.

The last point in that diagram matters for CI scripting: onEnd is asynchronous, and file reporters flush during it. A pipeline step that starts uploading playwright-report/ before the playwright test process has exited will sometimes capture a half-written report. Always let the test command finish before the upload step begins.

The built-in reporters

A reporter decides how results are printed and persisted. Playwright includes seven worth knowing, each suited to a different audience:

The first three are mutually exclusive in practice: they all write to the terminal, and running two at once produces interleaved garbage. The rest compose freely.

Comparison matrix of built-in Playwright reporters A matrix rating each built-in reporter on live terminal feedback, log size, machine parseability, and whether it links captured artifacts. reporter live view log size machine parse artifact links list per test verbose no no line updating small no no dot one char tiny no no html none folder no yes json none file yes paths junit none file yes paths blob none archive after merge yes
Only the html and blob reporters carry artifacts with them; json and junit record file paths, so their consumers need the artifact folder uploaded alongside.

That last column is the one teams get wrong most often. A junit.xml uploaded on its own gives your CI platform a list of failures with no evidence attached, because the XML holds only relative paths into test-results/. If you upload the XML and delete the output directory, every path in it dangles.

Configuring reporters

Set reporters in playwright.config.ts. You can run several at once — a terminal reporter for live feedback plus file reporters for CI to ingest — and switch the live one based on the CI environment variable:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  // Multiple reporters run together; each gets the same results.
  reporter: [
    // Compact dots in CI logs, readable list locally.
    [process.env.CI ? 'dot' : 'list'],
    // Interactive report; 'never' stops it auto-opening a browser in CI.
    ['html', { open: 'never', outputFolder: 'playwright-report' }],
    // Machine-readable output for the CI dashboard to parse.
    ['junit', { outputFile: 'results/junit.xml' }],
  ],
});

Every reporter option is a plain object, and the useful ones are easy to miss. html accepts outputFolder and open, where open may be always, never, or on-failure. junit accepts includeProjectInTestName, which prefixes each case with the project name so a cross-browser matrix does not collapse into duplicate-looking test names in the CI view, and stripANSIControlSequences, which removes the colour codes Playwright puts in error messages — without it some XML consumers render failure text full of escape gibberish. json accepts outputFile, and if you omit it the JSON goes to standard output, which is occasionally what you want when piping into another tool.

These reporter settings live beside the rest of your suite configuration documented in Playwright Config & Fixtures. Command-line flags override the config file: --reporter=line,json replaces the whole array for that invocation, which is the quickest way to get machine-readable output from a suite normally configured for humans.

Artifacts: the forensic evidence

Reporters tell you a test failed; artifacts tell you why. Playwright can capture three kinds, all configured under use:

The scope of each differs in a way that catches people out. Screenshots are per-page. Video is per context, so a test that opens three pages in one context produces one video containing all of them, and a test that creates a fresh context per user role produces one video per role — a detail that follows directly from how Browser Contexts & Isolation partitions a browser. Traces are per test, and each retry attempt gets its own trace file.

import { defineConfig } from '@playwright/test';

export default defineConfig({
  // Artifacts of failed tests land here; the folder is wiped at the start of a run.
  outputDir: 'test-results',
  retries: process.env.CI ? 2 : 0,
  use: {
    // PNG written during teardown of a failing test only.
    screenshot: 'only-on-failure',
    // Recording always runs; the file is deleted for tests that pass.
    video: 'retain-on-failure',
    // Attempt 1 runs untraced and fast; the first retry is fully recorded.
    trace: 'on-first-retry',
  },
});

Two configuration details are worth knowing before you ship this. outputDir is deleted at the start of every run, so any script that reads yesterday's artifacts must copy them elsewhere first. And the trace option accepts an object form — trace: { mode: 'on-first-retry', sources: false, screenshots: true, snapshots: true } — where sources: false stops your application and test source code from being embedded in the archive. If traces leave your network boundary, for example attached to a bug tracker used by a vendor, turning sources off is the difference between sharing a recording and shipping your codebase.

The practical recipe for wiring all three to failures and attaching them to the HTML report is covered step by step in Capturing Screenshots and Video on Test Failure.

Capture modes and the retry lifecycle

Artifact settings only make sense against the attempt lifecycle, because every conditional mode — only-on-failure, retain-on-failure, on-first-retry — is a rule about which attempt keeps its evidence. A test that passes first time should cost nothing; a test that fails should cost whatever it takes to explain itself.

Test attempt state machine and artifact retention A state machine showing an attempt passing, failing, consuming a retry, and being reported as flaky or as a final failure, with artifacts retained on the failing paths. attempt running passed flaky (reported) failed attempt retries left? retry attempt final failure pass fail yes no after a retry screenshot + video kept trace: 'on-first-retry' records only this attempt, not the original run
Conditional artifact modes are decisions taken on this graph: the passing edge discards evidence, the failing and retry edges keep it.

Read the graph and the trade-offs fall out. With trace: 'on-first-retry' and retries: 0 you will never get a trace, which is the single most common reason a CI failure arrives with no evidence attached — retries and artifacts are coupled, and configuring one without the other silently disables the second. If your policy is zero retries, use trace: 'retain-on-failure' instead, which records every attempt and deletes the archives for tests that passed. If you need the original failure rather than the reproduction, on-first-retry is actively wrong: a genuinely flaky test often passes on retry, so the trace you keep is the trace of a healthy run. Choosing between these is really a choice about retry policy, which is covered in Configuring Retries and Timeouts for Stable CI and used as a diagnostic signal in Flaky Test Management.

One more edge case: a test that fails during a beforeAll hook or during global setup may produce no artifacts at all, because no page existed when the failure occurred. Push assertions out of beforeAll and into the test body, or explicitly capture what you need in the hook.

Attaching your own evidence

The built-in artifacts capture the browser. They cannot capture the API payload you generated, the seeded database row, the feature flags in play, or the console trail that preceded a crash. testInfo.attach() closes that gap: anything you attach is stored beside the automatic artifacts, referenced in the JSON and JUnit output, and rendered inline in the HTML report — images and text are displayed, other types become download links.

import { test, expect } from '@playwright/test';

test('checkout produces a matching receipt', async ({ page }, testInfo) => {
  await page.goto('/checkout');

  // Capture the exact API response the UI is rendering from.
  const responsePromise = page.waitForResponse('**/api/orders');
  await page.getByRole('button', { name: 'Place order' }).click();
  const order = await (await responsePromise).json();

  // Attach the payload so a failure below is debuggable without re-running.
  await testInfo.attach('order-payload', {
    body: JSON.stringify(order, null, 2),
    contentType: 'application/json',
  });

  // outputPath() returns a per-test folder that survives into the report.
  const receiptPath = testInfo.outputPath('receipt.png');
  await page.getByTestId('receipt').screenshot({ path: receiptPath });
  await testInfo.attach('receipt', { path: receiptPath, contentType: 'image/png' });

  await expect(page.getByTestId('receipt-total')).toHaveText(`$${order.total}`);
});

Attaching inside each test is repetitive, so the pattern that scales is an automatic fixture that collects evidence for every test and only attaches it when the test did not end in its expected state:

import { test as base, expect } from '@playwright/test';

// An auto fixture runs for every test in the project without being requested.
export const test = base.extend<{ consoleTrail: void }>({
  consoleTrail: [async ({ page }, use, testInfo) => {
    const lines: string[] = [];
    // Buffer in memory rather than writing per message; most tests pass.
    page.on('console', (msg) => lines.push(`[${msg.type()}] ${msg.text()}`));
    page.on('pageerror', (err) => lines.push(`[pageerror] ${err.message}`));

    await use();

    // testInfo.status is only final after the test body and its hooks have run.
    const unexpected = testInfo.status !== testInfo.expectedStatus;
    if (unexpected && lines.length > 0) {
      await testInfo.attach('console.log', {
        body: lines.join('\n'),
        contentType: 'text/plain',
      });
    }
  }, { auto: true }],
});

export { expect };

Two constraints govern attachments. Names are not unique keys — attaching twice with the same name produces two entries, so include an index or a step name if you attach in a loop. And attachment bodies are copied into the report, so a hundred-megabyte database dump attached from a hundred tests will make the HTML report unusable long before it fails. Attach summaries, not raw dumps.

When you need to reshape results rather than add to them — post a Slack message, write to a flakiness database, emit an OpenTelemetry span per test — the hook contract from the sequence diagram above is the place to do it, and Writing a Custom Playwright Reporter walks through implementing those hooks in TypeScript, from a minimal class to a shard-safe production reporter.

Merging reports across sharded runs

Sharding is how large suites stay fast: split the tests across N machines with --shard=1/8, run them in parallel, and finish in an eighth of the wall time. It also breaks reporting, because each shard produces its own playwright-report/ and its own junit.xml, each describing one eighth of the truth. Uploading eight HTML reports and asking engineers to guess which one holds their failure is not observability.

The blob reporter exists for exactly this. Each shard writes an opaque archive containing its raw result events plus its artifacts; a separate merge step reads all the archives and renders them as though a single machine had run everything.

import { defineConfig } from '@playwright/test';

// Sharded CI runs emit blobs; local runs stay human-readable.
const sharded = Boolean(process.env.CI && process.env.SHARD_TOTAL);

export default defineConfig({
  // Shard index in the filename keeps parallel uploads from colliding.
  reporter: sharded
    ? [['blob', { outputFile: `blob-report/report-${process.env.SHARD_INDEX}.zip` }]]
    : [['list'], ['html', { open: 'never' }]],
  use: {
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
});

In the pipeline, each shard uploads its blob-report/ directory, a final job downloads all of them into one folder, and npx playwright merge-reports --reporter html,junit ./all-blob-reports produces the combined HTML site and a single JUnit file. The merge step is a normal Node process with no browsers involved, so it runs in seconds on the cheapest runner you have. Because artifacts travel inside the blobs, trace links in the merged report resolve correctly even though the traces were recorded on eight different machines.

Watch two things. Blob archives include the artifacts, so they are large — apply a retention window on your CI artifact store or the bill grows quietly. And the merge is only meaningful when every shard ran the same Playwright version and the same config; a partially upgraded pipeline produces a merge error rather than a silently wrong report. The full sharding topology, including how to compute shard indices, is set out in Running Playwright Tests in GitHub Actions with Sharding.

Reading a trace

The trace is the richest artifact because it lets you replay a failure you never saw, scrubbing through the action timeline with before-and-after DOM snapshots and a full network panel. Each recorded action carries the locator that was used, how long it waited, and the snapshot of the page immediately before and after it ran — which is how you distinguish "the element never appeared" from "the element appeared and something else covered it". Opening and interpreting a trace is the subject of Trace Viewer & Debugging, with the request and console panes covered in Reading Network and Console Tabs in Traces and a worked investigation in Analyzing Test Failures with the Playwright Trace Viewer.

Capturing one is cheap with trace: 'on-first-retry', so a failed test on CI almost always leaves a trace you can pull down and open with npx playwright show-trace trace.zip. The trace is also where visual differences surface as concrete pixels rather than percentages, which pairs naturally with Visual Regression Testing when a snapshot comparison fails and you need to see what the page actually looked like at the moment of capture.

Wiring artifacts into CI

Artifacts only help if they survive the build. Four rules cover nearly every pipeline.

Write to a known folder. Set outputDir and the reporters' output paths explicitly rather than relying on defaults, so the upload step's glob cannot drift out of sync with the runner's output.

Upload unconditionally. The upload step must run even when the test step failed — in GitHub Actions that means if: always(). A pipeline that only uploads on success uploads artifacts nobody needs and discards the ones somebody does. This is the single most common cause of "CI says failed but there are no artifacts".

Namespace by shard and attempt. Uploading playwright-report from eight shards to the same artifact name either errors or silently overwrites. Suffix with the shard index, or use blob reports and merge.

Set retention deliberately. Traces and video dominate storage. A fourteen-day window on artifacts and a ninety-day window on the merged JUnit XML gives you debugging depth where it matters and trend data where it is cheap.

The complete pipeline — caching browsers, sharding, and publishing these outputs — is described in CI/CD Integration. The result is a failed build where every red test links directly to its screenshot, video, and trace.

Failure modes and debugging

The HTML report opens but every link is broken

Opening playwright-report/index.html directly from the filesystem serves it over the file:// protocol, where the browser blocks the fetches the report uses to load its data and refuses to open trace archives. Run npx playwright show-report instead, which serves the folder over HTTP on localhost. The same applies to a report published to a static host: it must be served over HTTP, not linked as a downloaded file.

The JUnit file exists but reports zero tests

Almost always a path problem. outputFile is resolved relative to the config's root directory, not the working directory of the CI step, so a relative path that works locally can land somewhere unexpected in the container. Print the resolved path in the job, or use an absolute path built from process.cwd(). The other cause is a shard overwriting the file another shard wrote, leaving only the last shard's results.

The report is gigabytes

Somebody set video: 'on' or trace: 'on' while debugging and it reached the main branch. Both record unconditionally for every test including the thousands that pass. Switch to the conditional modes, and if you truly need always-on video, cap the frame size with video: { mode: 'on', size: { width: 800, height: 600 } } to cut the file size by an order of magnitude.

Artifacts appear for some tests and not others

Check whether the tests that produced nothing failed before a page existed — a failure in beforeAll, in global setup, or in a fixture that throws while constructing the context leaves no browser to screenshot. Check also whether those tests were skipped or timed out at the suite level, where teardown is cut short and pending writes are abandoned.

The trace shows the retry, not the failure

Expected behaviour for on-first-retry, and a real limitation when the bug only manifests on the first attempt against a clean database. Switch that project to retain-on-failure so every attempt is recorded, and accept the extra wall time on the affected suite only rather than across the whole run.

Deep dives beneath this guide

Two pages take individual pieces of this system further than a guide can. Capturing Screenshots and Video on Test Failure is the configuration-level recipe: the exact use block, per-project overrides, and how the captured files end up linked from the HTML report. Writing a Custom Playwright Reporter goes the other direction, implementing the hook contract yourself so results can be pushed into a dashboard, a chat channel, or a flakiness database as the run proceeds.

Frequently Asked Questions

Can I run more than one reporter at once?

Yes. The reporter option accepts an array, so a typical setup pairs a terminal reporter such as list or dot for live feedback with file reporters like html and junit for CI to ingest. Each reporter receives the same results independently, so adding one never changes another's output.

The exception is terminal reporters: list, line, and dot all render to standard output, and running two of them together interleaves their writes into unreadable output. Pick exactly one terminal reporter and compose it with as many file reporters as you need.

Which reporter should I use in CI?

Use a compact terminal reporter like dot to keep logs short, plus junit so the CI platform renders a native test view, and html with open: 'never' so a rich report is uploaded as a downloadable artifact. Reserve the verbose list reporter for local runs where per-test context is more useful than brevity.

If the run is sharded, replace that combination with the blob reporter on each shard and a single merge-reports step at the end, which produces one HTML report and one JUnit file covering every shard.

What is the difference between a screenshot and a trace?

A screenshot is a single image of the page at one moment, useful for a quick visual of the failure state, while a trace is a full replayable recording of every action, network request, and DOM snapshot across the whole test. A trace is far richer for diagnosis but larger, which is why it is usually captured only on the first retry.

Why did my CI run produce no trace at all?

The most likely cause is trace: 'on-first-retry' combined with retries: 0. That mode only records on a retry attempt, so with retries disabled no attempt ever qualifies. Either set retries to at least one on CI, or change the mode to retain-on-failure, which records every attempt and discards the archives belonging to tests that passed.

A second cause is an upload step that runs only when the test step succeeds. Mark the upload to run unconditionally so evidence from a red build actually leaves the runner.

How do I attach my own data to a test report?

Call testInfo.attach() with either a body buffer or string, or a path to a file on disk, plus a contentType. Text and images are rendered inline in the HTML report and every other type becomes a download link, while the JSON and JUnit reporters record the attachment path. Wrapping the call in an automatic fixture attaches evidence for every test without touching individual test bodies.

Keep attachments small and summarised. Because bodies are copied into the report bundle, attaching large dumps from many tests inflates the report until it is slow to open and expensive to store.

Do reporters slow down the test run?

The built-in reporters are effectively free — they process an event stream in the main process while workers do the real work. Artifacts are where the cost lives: video recording runs for the full duration of every test regardless of outcome, and full tracing adds meaningful overhead to each action. Conditional modes such as retain-on-failure remove the storage cost but not the recording cost, so a suite that is slow with video on will stay slow even if almost every test passes.

Back to overview