Home / Blog / Playwright reporting

Playwright reporting: the HTML report and who reads it

Quick answer

Playwright ships eight built-in reporters, and choosing between them is a question about people: who reads this run, and when. A local run wants list. A pull request wants annotations on the diff. A sharded nightly wants blob reports merged into one HTML report, which a browser has to fetch over http rather than open off the disk.

docs/test-reporters is the reference and it is complete. Every option is there with its default and its environment-variable twin, it is free, and it is the first result. Look an option up there. It cannot help you with the decision underneath: it is organised by output format, eight subsections each describing what one reporter prints, and nowhere in it does anything say who the output is for.

Every sample below ran on Playwright 1.62 — 1.62.1 exactly, which is what an unpinned npm install returned on the day. The runner and its reporters are a JavaScript and TypeScript feature, so the npm line is the one that decides the patch here. One reporting change landed in the minor: the HTML report's merge files grouping, which used to be a toggle inside the report, can now be set from the config as mergeFiles.

Who is going to read this run, and when?

Four people can read a Playwright run, and no two of them want the same thing.

You, ten seconds after pressing Enter. You are watching the terminal. You want a line per test as it happens and the failure printed at the moment it happens, so you can stop the run and go and fix it.

The author of a pull request, some minutes later. They have moved on. They are looking at a diff, or at Slack, and they are not going to open a log to find out whether their branch is fine. Output that sits somewhere waiting to be opened does not reach them.

Whoever opens the nightly tomorrow morning. They were asleep when it ran. They need a durable artifact with the failure, the screenshot, the trace and enough context to decide before standup whether it matters.

A machine. A dashboard, a test-management tool, or the CI server's own test tab. The run is one row in something that has memory, which the HTML report does not have.

Who is readingWhenWhat they needReporter
You, at the terminalWhile it runsA line per test, and the failure where it happenedlist
The author of a pull requestMinutes later, in a diffThe failure attached to the line that caused itgithub
Whoever opens the nightlyNext morningScreenshots, traces, a record that survives the runnerhtml
A machineWhenever it pollsA parseable record of one runjunit, json

Most teams pick one reporter and hand it to all four. Three of them are then served badly, and the one being served is usually the first — the person who was already watching and did not need the help.

What do you get by default, and why is CI different?

list is the default on your own machine. docs/test-cli documents --reporter with default: "list", and the reporter reference says the same thing from the other side, in the comment on its own configuration example.

dot is the default on CI, and the reference gives the reason: it is concise and avoids too much output. You can watch the swap happen. Running the same three-test suite twice here, with no reporter key in the config at all, the local run printed a numbered line per test and then the failure block. With CI=1 in the environment, everything before the failure block was three characters: ··F.

That default is sound and this page is not arguing with it. A CI log is a machine record, it can run to thousands of lines, and one character per test keeps it small. It is a format chosen for log volume, being read by people who assumed it was chosen for legibility — and it is optimised for the one reader who is definitely not present when CI runs.

Can you run more than one reporter at once?

Yes. reporter takes an array; each entry is a reporter name with its own options; and you stop choosing a reporter and start choosing a set, one entry per reader.

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

export default defineConfig({
  testDir: './tests',
  reporter: process.env.CI
    ? [
        ['blob'],
        ['github'],
        ['html', { open: 'never' }],
        ['junit', { outputFile: 'results.xml' }],
      ]
    : 'list',
});
Playwright 1.62.1 · TypeScript · playwright.config.ts

One run with that config produced all four outputs: a blob-report/report.zip, two GitHub workflow commands on stdout, a playwright-report/ folder and a results.xml. Read the array aloud and name the person each line serves. list is you, and it is only in the local branch because on CI nobody is watching. blob is the merge job. github is the author of the pull request. html is whoever opens the nightly. junit is the machine. An entry you cannot attach to a reader is one that got in because it was in the reference.

Two options in there change what happens, not how it looks. open takes 'always', 'never' or 'on-failure', and 'on-failure' is the default. outputFolder defaults to playwright-report, which is the path every artifact upload step in your pipeline is quietly hard-coded to. The HTML reporter has ten option and environment-variable pairs beyond those two, and the reference lists them better than a paragraph can.

Why is the HTML report empty when you open index.html?

You downloaded the artifact from a red job, unzipped it, double-clicked index.html, and got something that is not the report you see on your laptop. The sentence that explains it is on docs/ci-intro, under Viewing the HTML Report: "Locally opening the report does not work as expected as you need a web server for everything to work correctly." That sentence does not appear on docs/test-reporters at all — checked through two doors, the rendered page and the raw markdown at the 1.62.1 tag. So the reporter page hands you the report and never mentions the constraint, and the page that does mention it is a GitHub Actions tutorial you had no reason to open.

Every measurement below comes from one three-test suite with one deliberate failure in it, run on an eight-core Windows 11 laptop with Node 20.19.6, Playwright 1.62.1 and Chromium 151.0.7922.34.

With the defaults the report is not blank. playwright-report/index.html came out as a single 518,859-byte file with its assets inlined, and over file:// it rendered the run summary, the test list, the failure message, the call log and the source snippet. The failure screenshot rendered too. What broke was every fetch into the data/ folder beside it, refused with Fetch API cannot load … URL scheme "file" is not supported, which costs you the error-context attachment and the Copy prompt button. Clicking View Trace replaced the pane with Playwright saying the same thing in its own words: the trace viewer has to be loaded over http:// or https://, followed by the show-report command to run instead.

The genuinely blank page has a narrower cause. doNotInlineAssets, which you need under a Content Security Policy that forbids inline scripts, writes the report data out beside the page instead: the same suite gave a 4,337-byte index.html with a 415,216-byte report.js and a 99,364-byte report.css next to it. Opened over file://, Chromium blocked both as cross-origin requests from a null origin and the body rendered zero characters.

One command fixes either version. npx playwright show-report serves the last report on port 9323, or on any free port when 9323 is taken; npx playwright show-report my-report serves a custom outputFolder. The two documentation pages look as though they disagree about the step before that. docs/ci-intro says extract the zip first; the reporter page hands show-report the zip itself. Running both settles it. Handed an archive with index.html at its top level, show-report served it. Handed one with the containing folder zipped instead, it printed No "index.html" found at the top level of "nested.zip" and exited 1. Extracting first always works; passing the zip works on an archive of the right shape; and the thing to look at before you type either is the top level of the zip you were given.

None of that shortens the journey by much. Somebody still has to open the run, get to the artifacts, download the zip and put it in front of a browser that Playwright is serving — the workflow page that owns the upload step counts those steps and makes it five. Five is the reason the artifact is never opened.

Where did the report go when you sharded the run?

Everything works right up until the day you add a second shard. Then the artifacts tab holds four zips with the same name and a number after it, and none of them is the run.

Each shard is a separate playwright test process with its own reporters, so N shards write N reports. There is no run-level report because at no point was there a process that saw the whole run.

blob is the reporter that fixes it. A blob report holds all the detail of a run and exists to be merged with the others afterwards, which makes it the only reporter on the list whose audience is another Playwright process. It lands in blob-report/. The file is report-<hash>.zip, or report-<hash>-<shard_number>.zip when sharding is on, and the hash is optional — it is computed from --grep, --project, the config tag and any file filters, so two shards run with none of those produced plain report-1.zip and report-2.zip here.

Point npx playwright merge-reports --reporter html ./all-blob-reports at the directory you collected them into and one report comes back. Two shards of a five-test suite merged here into a report reading All 5, Passed 4, Failed 1.

blob is a recording, and every other reporter is a format. You merge once and produce whatever each reader needs from the same recording, without running a test again: --reporter=html,github in one invocation gives a person the report and gives the pull request its annotations. How many shards to run in the first place is a separate sum, and the arithmetic for a shard count works it out.

How do you make a failing test explain itself?

An assertion message tells you which expectation failed. It does not tell you which browser, which environment, which seeded account, or what the server sent back — and those are the four things the person reading tomorrow morning cannot reconstruct. Two mechanisms put them in the report, and both live on the test rather than in the reporter config.

Where the failure lands is the reporter choice, and for the author of a pull request the answer is github: it marks up the pull request itself, putting each failure beside the code that produced it, and it is not on by default. Which job carries it, and what the annotation looks like once it lands, are decided in the workflow file.

Make the failure carry its own context

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

test('basket total is priced in dollars', {
  annotation: { type: 'issue', description: 'https://github.com/acme/shop/issues/412' },
}, async ({ page, browser }) => {
  test.info().annotations.push({ type: 'browser', description: browser.version() });
  test.info().annotations.push({ type: '_runner', description: process.env.RUNNER_NAME ?? 'laptop' });

  await page.setContent('<p id="total">$42.00</p>');
  const total = await page.locator('#total').innerText();

  await test.info().attach('basket.json', {
    body: JSON.stringify({ currency: 'USD', total }, null, 2),
    contentType: 'application/json',
  });

  expect(total).toBe('$42.00');
});
Playwright 1.62.1 · TypeScript · tests/checkout.spec.ts · passes

The declarative annotation block goes on the test definition, and it is the place for anything you know while writing the test: an issue URL, an owner, a category. test.info().annotations.push() adds one while the test is running, which is where the browser version, the environment name and the seeded account number come from.

Serving that report showed issue and browser: 151.0.7922.34 under Annotations, and showed nothing for _runner. The built-in HTML reporter displays every annotation except those whose type starts with an underscore, so the underscore is how you pass a value to a custom reporter or a downstream script without cluttering the report a person reads.

Attach the evidence instead of describing it

testInfo.attach() puts a value or a file from disk onto the test result, so the API response that caused the failure lands in the report beside the failure instead of in a log nobody kept. The trace is the attachment you already have — what to look at inside one, and in what order.

Whatever a failing run says, somebody should be able to act on it from the notification alone. If the answer to "what does this red job tell them" is "to open the report", the report has become the message, and that message is five steps long.

Getting a pipeline to that standard is work we take on, and we do it as an engagement for teams whose pipeline itself is the thing that has stopped working.

Who reads a report that is not a person?

json and junit are interchange formats, not reports. JUnit XML is what a CI server's own test tab and most test-management tools ingest; JSON is what you write a script against. The three-test run above produced a 3,245-byte results.xml whose root element carried tests="3" failures="1", which is the whole of what a dashboard wants from it.

That reader usually appears for one reason. The HTML report describes one run and holds no memory of any other, so a lead who has been asked for a weekly pass rate finds that pass rate over time, flake trends and per-test history are not in it. allure-playwright is where most teams land next; it has real documentation at allurereport.org, which shows the package going into the reporter array beside a built-in one and leaving it in place. We name it because it is what people ask about. We do not resell reporting dashboards or hosted report services, so nothing here is a recommendation to buy one.

When no format fits the reader you have, the Reporter API is small enough to write for in an afternoon. It has twelve methods and a useful reporter needs two of them.

import path from 'path';
import type { FullResult, Reporter, TestCase, TestResult } from '@playwright/test/reporter';

class FailureLines implements Reporter {
  private failures: string[] = [];

  onTestEnd(test: TestCase, result: TestResult) {
    if (result.status === 'passed' || result.status === 'skipped')
      return;
    const where = `${path.basename(test.location.file)}:${test.location.line}`;
    const why = (result.error?.message ?? result.status).split('\n')[0].replace(/\x1b\[[0-9;]*m/g, '');
    this.failures.push(`${where}  ${test.title}  ${why}`);
  }

  onEnd(result: FullResult) {
    console.log(`Run ${result.status}, ${this.failures.length} to fix:`);
    for (const failure of this.failures)
      console.log(`  ${failure}`);
  }
}

export default FailureLines;
Playwright 1.62.1 · TypeScript · failure-lines.ts

Point reporter: './failure-lines.ts' at it in the config, or pass --reporter="./failure-lines.ts" on the command line, and the entire output of the three-test run used throughout this page becomes two lines: Run failed, 1 to fix: and cart.spec.ts:16 cart total is in euros Error: expect(locator).toHaveText(expected) failed. That replace call is not decoration — result.error.message arrives with terminal colour codes in it, which is fine in a shell and unreadable in a Slack message or a text file. The JUnit reporter carries a stripANSIControlSequences option for the same reason.

When this bites you

Questions

How many reporters does Playwright have built in?

Eight have a section of their own in the reference, in this order: list, line, dot, html, blob, json, junit and github. The eighth is the odd one, because it does not produce a report at all — it writes failure annotations onto a GitHub Actions run, against the lines that failed. Someone counting report formats gets seven and someone counting the reference's subsections gets eight, which is why the number moves around when you read about it.

Why is my Playwright HTML report blank?

Because a browser opening it off the file system cannot fetch the files beside it. On Playwright 1.62.1 with the default settings the report is not fully blank: index.html carries its assets inlined, so the summary, the test list and the failure text render, while the attachments under data/ and the trace viewer do not. Turn on doNotInlineAssets and it does go blank — index.html drops to a few kilobytes, Chromium blocks report.js and report.css as cross-origin requests from a null origin, and the body of the page renders nothing at all. The fix in both cases is npx playwright show-report, which serves the report over http on port 9323.

How do I get one report from a sharded run?

Give each shard the blob reporter, collect the zip files it writes into blob-report/ from every shard into one directory, and run npx playwright merge-reports --reporter html ./all-blob-reports over that directory. Without it there is no run-level report at all, because each shard is a separate playwright test process with its own reporters and no process ever saw the whole run. The merge reads a recording rather than a format, so one merge can produce HTML for a person and JUnit for a dashboard without re-running a test.

Can I use more than one reporter at the same time?

Yes. The reporter option takes an array, and each entry is a reporter name with its own options, so one run can print a line per test, write annotations onto a pull request, build an HTML report and emit JUnit XML. Choose the set by reader: one entry for each person or system that is going to read this run. An entry you cannot attach to a reader is one you copied out of the reference.

Does Playwright's HTML report keep history between runs?

No. The report describes one run — its tests, their durations, their annotations and their attachments. It contains no pass rate over time, no flake trend and no per-test history, because nothing inside it refers to any other run. Teams that need those export each run in an interchange format, junit or json, into something that stores runs, or add a third-party reporter such as allure-playwright beside a built-in one.

What happens in the ten minutes after a nightly goes red?

Who finds out, how they find out, and what they have to open before they know which test broke. Send us that sequence and you get back which of its steps a change to the reporter array deletes outright, and which of them need the pipeline itself moved. If there is no nightly yet, say so and we start from what you have.