CI/CD
Playwright Reporters: HTML, JUnit, Blob and Writing Your Own
What each built-in Playwright reporter is for, how to combine them, and a short custom reporter that posts a run summary where you need it.

Playwright reporters decide what a test run leaves behind: a line per test in your terminal, an HTML report you can click through, a JUnit file your CI server reads, or a blob that a later job merges. You can run several at once, each for a different reader.
This guide covers what each built-in reporter is for, how to combine them without losing output, and how to write a short custom reporter that posts a run summary to a webhook. Every command and output below comes from runs with @playwright/test 1.64.1, the latest release as of October 2026, against a six-test suite that opens a local HTML file.
Key takeaways
- Locally, Playwright defaults to
list. When theCIvariable is set, it defaults todot. As soon as you configure reporters yourself, the defaults are gone. --reporteron the command line replaces the reporters in your config.--add-reporter(Playwright 1.63+) keeps them and adds more.- JUnit and JSON print to stdout unless you give them a file. Always set
outputFile. - JUnit shows a flaky test as a plain pass. If you care about flakiness, keep JSON or HTML next to it.
- Blob reports are for merging shards. Each blob run empties its output folder first, so two local shards in one folder keep only the last.
- A custom reporter is a class with a few hooks.
onTestEndruns once per attempt, and an error thrown fromonTestEndoronEndfails the run.
Built-in Playwright reporters and what each is for
These are the reporters on Playwright's docs page, plus null. You pick them by name in playwright.config.ts or with --reporter.
| Reporter | What it writes | Use it for | Watch out for |
|---|---|---|---|
list |
One line per test in the terminal | Local runs (default without CI) |
Long CI logs on big suites |
line |
One updating line, failures inline | Large suites in an interactive terminal | Less useful in CI logs |
dot |
One character per test | CI logs (default with CI) |
You read failures at the end, not as they happen |
html |
A folder with a self-contained report | Humans debugging a run, with traces and screenshots | The folder is cleared on every run |
junit |
One JUnit XML file | CI servers that show test results | Flaky tests look like passes |
json |
One JSON file with every result | Your own scripts and dashboards | Prints to stdout without outputFile |
blob |
A zip with the full run, attachments included | Sharded runs you merge later | Not human-readable on its own |
github |
::error workflow commands |
GitHub Actions only: file annotations | Playwright's docs advise against it with matrix builds |
perfetto |
test-results/perfetto.json in Trace Event Format (1.63+) |
Seeing workers on a timeline | A profiling tool, not a results report |
null |
No report | Accepted in 1.64.1, but not on the docs page | It does not silence the terminal: Playwright still adds line or dot |
The terminal reporters differ only in how much they show for passing tests; all print full failure details. This is a real dot run of the example suite in CI mode, with two retries, one failing test and one flaky test:
Running 6 tests using 6 workers
°···××±×FReading left to right: ° is a skipped test, · a pass, × a failure that will be retried, ± a test that passed on retry (flaky), and F a test that failed on its last attempt. The reporters page in the Playwright docs lists every option for each reporter.
The github reporter prints GitHub workflow commands wherever it runs. A failure in the example came out as ::error file=tests/subscribe.spec.ts,…,line=33,col=42::…, which GitHub Actions shows as an annotation on that line. Anywhere else it is noise in the log, so enable it only on GitHub: reporter: process.env.GITHUB_ACTIONS ? [['github'], ['dot']] : [['list']].
Combine multiple reporters in playwright.config.ts
The reporter option takes an array of [name, options] tuples. Every reporter in the array gets the same events and writes its own output. This config uses list and HTML locally, and four reporters in CI:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
const isCI = !!process.env.CI;
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: isCI ? 2 : 0,
use: { trace: 'on-first-retry' },
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
reporter: isCI
? [
['dot'],
['junit', { outputFile: 'reports/junit.xml' }],
['html', { outputFolder: 'reports/html', open: 'never' }],
['./reporters/summary-reporter.ts', { webhookUrl: process.env.SUMMARY_WEBHOOK_URL }],
]
: [['list'], ['html', { open: 'on-failure' }]],
});With CI=1 and SUMMARY_WEBHOOK_URL set, npx playwright test printed the dot line and failure details, wrote reports/junit.xml and reports/html/, and posted a summary from the custom reporter, which is built later in this guide.
Four rules save you from the usual surprises:
Listing reporters replaces the default. Once reporter is set, Playwright stops picking list or dot for you. If you want terminal output in CI, name dot or list in the array.
--reporter replaces the config, --add-reporter adds to it. With the config above, npx playwright test --reporter=junit printed only XML: no progress lines and no HTML report. To keep the configured reporters and add more, use --add-reporter, new in Playwright 1.63:
PLAYWRIGHT_JUNIT_OUTPUT_FILE=out/junit.xml PLAYWRIGHT_JSON_OUTPUT_FILE=out/results.json npx playwright test --add-reporter=junit,jsonThat run kept the list output and the HTML report, and wrote both files under out/. The PLAYWRIGHT_*_OUTPUT_FILE variables give a file to a reporter added from the command line.
File reporters need a file. Without outputFile, JUnit and JSON write to stdout. Mixed with dot, you get XML in the middle of your log:
$ npx playwright test --reporter=dot,junit tests/cart.spec.ts
Running 2 tests using 2 workers
°·
1 skipped
1 passed (898ms)
<testsuites id="" name="" tests="2" failures="0" skipped="1" errors="0" time="0.89789">Keep the HTML report and test-results apart. The HTML reporter empties its folder before it writes, and the test output folder (outputDir, test-results by default) holds each test's traces and screenshots. When one folder sits inside the other, Playwright prints a warning. With outputFolder: 'test-results/html', the run printed this, wrote the report anyway and exited 0:
Configuration Error: HTML reporter output folder clashes with the tests output folder:
...
HTML reporter will clear its output directory prior to being generated, which will lead to the artifact loss.The worse case is silent. With outputFolder: 'test-results', the same folder, 1.64.1 printed no warning, and after a run with trace: 'on' the per-test traces were gone and the report had no trace data. Use a sibling folder such as reports/html or the default playwright-report, and in CI set open: 'never'.
JUnit for CI systems
Most CI servers can read JUnit XML and show a test tab or a summary. Playwright's junit reporter writes one <testsuite> per spec file and one <testcase> per test. Here is the start of the file from the CI run above:
<testsuites id="" name="" tests="6" failures="1" skipped="1" errors="0" time="4.699163">
<testsuite name="cart.spec.ts" timestamp="2026-10-22T14:29:27.189Z" hostname="chromium" tests="2" failures="0" skipped="1" time="0.207" errors="0">
<testcase name="lists the cart items" classname="cart.spec.ts" time="0.138">
</testcase>Things worth knowing before you rely on it:
- Flaky tests look green. The test that failed once and passed on retry appears as a
<testcase>with no<failure>. Only its attachments, including the retry'strace.zip, hint that something happened. JUnit has no "flaky" state, so if you track flakiness, keep the JSON or HTML report too. See Playwright retries for how to read retried runs. - One test case per test, not per attempt. A test that failed all three attempts is one
<testcase>with one<failure>element, whose text holds the error. hostnameis the project name. With several projects, the same test name appears more than once. GitLab, for one, keeps only the first test with a given name, so setincludeProjectInTestName: true(orPLAYWRIGHT_JUNIT_INCLUDE_PROJECT_IN_TEST_NAME=1). The test then reads[chromium] lists the cart items.- Attachments are linked by path. Playwright adds
[[ATTACHMENT|path]]lines to<system-out>, with paths relative to the JUnit file. WithoutputFile: 'reports/junit.xml'they start with../test-results/. GitLab reads these paths relative to the project directory, so for GitLab writejunit.xmlat the project root, where the paths start withtest-results/.
The JUnit report never changes the job's status. The exit code of playwright test does that. For a complete GitLab job that publishes JUnit in merge requests, see Playwright in GitLab CI.
JSON when a script needs the results
If a script needs the numbers, JSON is easier to work with than XML. It keeps the flaky state and every attempt. This script prints the totals and the tests that need attention:
// scripts/json-stats.mjs: node scripts/json-stats.mjs out/results.json
import fs from 'node:fs';
const report = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
console.log(report.stats);
const walk = (suite) => [...(suite.specs ?? []), ...(suite.suites ?? []).flatMap(walk)];
for (const spec of report.suites.flatMap(walk)) {
for (const test of spec.tests) {
if (test.status === 'unexpected' || test.status === 'flaky') {
console.log(`${test.status}: ${spec.file} > ${spec.title} (${test.results.length} attempts)`);
}
}
}After a CI-mode run that wrote out/results.json, it printed:
{
startTime: '2026-10-22T14:31:12.813Z',
duration: 4859.255999999999,
expected: 3,
skipped: 1,
unexpected: 1,
flaky: 1
}
flaky: subscribe.spec.ts > shows the status message (2 attempts)
unexpected: subscribe.spec.ts > keeps the email after subscribing (3 attempts)Blob reports for sharded runs
When you shard a suite across machines, each machine sees only its slice. An HTML report per shard gives you one partial view per machine. The blob reporter records everything about the run, attachments included, in one zip per shard. A later step merges the zips into any other report.
One behaviour catches people out when they try this on one machine. The blob reporter empties its output folder before it writes. Two shards run one after another in the same folder leave only the second:
for i in 1 2; do npx playwright test --shard=$i/2 --reporter=blob; ls blob-report; donereport-1.zip
report-2.zipAfter shard 2 the folder holds only report-2.zip. On CI each shard has its own machine. Locally, give each shard its own folder:
for i in 1 2; do PLAYWRIGHT_BLOB_OUTPUT_DIR=blob-shard-$i npx playwright test --shard=$i/2 --reporter=blob; done
mkdir -p all-blob-reports && cp blob-shard-*/*.zip all-blob-reports/To merge, you can pass reporters with --reporter html, or use a small config so the merge writes everything your CI needs in one pass:
// merge.config.ts: used only by `npx playwright merge-reports --config merge.config.ts <folder>`
export default {
testDir: './tests',
reporter: [
['junit', { outputFile: 'reports/junit.xml' }],
['html', { outputFolder: 'reports/html', open: 'never' }],
['./reporters/summary-reporter.ts', { webhookUrl: process.env.SUMMARY_WEBHOOK_URL }],
],
};npx playwright merge-reports --config merge.config.ts ./all-blob-reportsmerging reports from /home/.../playwright-reporters/all-blob-reports
extracting: all-blob-reports/report-1.zip
extracting: all-blob-reports/report-2.zip
merging events
processing test events
building final report
finished building reportWith the demo failures switched on, the merged JUnit file had all six tests, one failure and one skip, like an unsharded run, and the custom reporter summarised the whole run. Two more things to know:
- The merge exits 0 even when tests failed. Let the shard jobs fail the pipeline.
- Tag runs from different environments. If you merge blobs from, say, two operating systems, set
tagin each config so the merged report tells them apart. The tag also changes the blob file names.
The sharding guide has the full GitHub Actions and GitLab jobs that upload blobs and merge them, and the fix for merging blobs recorded in different checkout paths.
Write a custom Playwright reporter
When none of the built-in reporters fits, you write your own. A reporter is a class that implements some of the methods of the Reporter interface. All of them are optional. This is the order Playwright calls them in:

The example below writes a short Markdown summary when the run ends: to a file, which on GitHub Actions is the job summary via GITHUB_STEP_SUMMARY, and optionally to a webhook such as a chat channel's.
// reporters/summary-reporter.ts
import fs from 'node:fs';
import type { FullConfig, FullResult, Reporter, Suite, TestCase, TestResult } from '@playwright/test/reporter';
type Options = { outputFile?: string; webhookUrl?: string };
const firstLine = (text = '') => text.replace(/\u001b\[[0-9;]*m/g, '').split('\n')[0];
export default class SummaryReporter implements Reporter {
private root?: Suite;
private errors = new Map<string, string>();
constructor(private options: Options = {}) {}
onBegin(_config: FullConfig, suite: Suite) {
this.root = suite;
}
// Called once per attempt: a retried test ends here more than once.
onTestEnd(test: TestCase, result: TestResult) {
if (result.error) this.errors.set(test.id, firstLine(result.error.message));
}
async onEnd(result: FullResult) {
const tests = this.root?.allTests() ?? [];
const count = (outcome: string) => tests.filter((t) => t.outcome() === outcome).length;
const failed = tests
.filter((t) => t.outcome() === 'unexpected')
.map((t) => `- ${t.titlePath().filter(Boolean).join(' > ')}: ${this.errors.get(t.id) ?? t.results.at(-1)?.status}`);
const text = [
`### Playwright: ${result.status}`,
`${count('expected')} passed, ${count('unexpected')} failed, ${count('flaky')} flaky, ` +
`${count('skipped')} skipped in ${(result.duration / 1000).toFixed(1)} s`,
...failed,
].join('\n');
const file = this.options.outputFile ?? process.env.GITHUB_STEP_SUMMARY;
if (file) fs.appendFileSync(file, text + '\n');
if (!this.options.webhookUrl) return;
try {
const res = await fetch(this.options.webhookUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text }),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) console.error(`summary-reporter: webhook answered ${res.status}`);
} catch (error) {
console.error(`summary-reporter: webhook failed: ${(error as Error).message}`);
}
}
printsToStdio() {
return false;
}
}Register it by path, with options as the tuple's second element, as in the config earlier. Playwright loads TypeScript reporters directly, with no build step.
To try it without a chat service, start the example repo's tiny receiver, which prints what it gets on 127.0.0.1:8787, and run the suite in a second terminal. DEMO_FLAKY and DEMO_FAILURE make one example test flaky and one fail:
node scripts/webhook-receiver.mjs 8787
CI=1 DEMO_FLAKY=1 DEMO_FAILURE=1 SUMMARY_WEBHOOK_URL=http://127.0.0.1:8787/hook npx playwright testThe receiver printed this request:
POST /hook -> 200
{"text":"### Playwright: failed\n3 passed, 1 failed, 1 flaky, 1 skipped in 4.7 s\n- chromium > subscribe.spec.ts > keeps the email after subscribing: Error: expect(locator).toHaveValue(expected) failed"}The design choices that matter, and why:
Count outcomes, not onTestEnd calls. onTestEnd fires for every attempt. With two retries, a test that always fails ends three times, and a flaky test ends twice, once failed and once passed. Counting there double-counts. test.outcome() gives one verdict per test: expected, unexpected, flaky or skipped. onTestEnd is still the right place to catch each error message.
printsToStdio() returning false keeps the terminal useful. When no configured reporter prints to the terminal, Playwright adds line locally or dot in CI, so --reporter=./reporters/summary-reporter.ts alone still showed progress. Leave the method out and the reporter counts as printing, so as the only reporter it leaves the terminal silent.
Catch your own errors. Playwright's docs say it swallows errors thrown in reporter methods. In 1.64.1 that means it catches and prints them, but it also counts them. A reporter whose onEnd throws turned a green run into exit code 1:
1 skipped
5 passed (964ms)
Error: upload failed (simulated)
at ThrowingReporter.onEnd (.../experiments/throwing-reporter.ts:6:11)The same happens in onTestEnd (6 errors were not a part of any test). A chat service that is down should not fail your build, so the example logs network errors and non-2xx answers. With the receiver answering 500, the run printed summary-reporter: webhook answered 500 and exited 0.
Fail the run on purpose, not by accident. Sometimes a missing upload should fail the build, for example when results feed a release gate. onEnd can return a status, and Playwright uses it for the exit code:
// experiments/status-override-reporter.ts
import type { FullResult, Reporter } from '@playwright/test/reporter';
export default class StatusOverrideReporter implements Reporter {
async onEnd(result: FullResult) {
const delivered = false; // pretend the upload was not acknowledged
if (result.status === 'passed' && !delivered) {
console.error('results were not delivered: failing the run');
return { status: 'failed' as const };
}
}
}That run passed all its tests and exited 1. Only override a passed run, so a real failure keeps its own cause.
onEnd or onExit? onExit runs after every reporter's onEnd, when the HTML folder already exists, and the docs suggest it for uploads; it cannot change the status, so uploads that may fail the run belong in onEnd.
Blendra's reporter
If you build your suite with Blendra, the exported Playwright TypeScript project is a normal Playwright repo, and its config combines reporters the same way this guide does. In the export's playwright.config.ts, the array reads:
// playwright.config.ts of a Blendra export (fragment)
reporter: [
['list'],
['html', { outputFolder: 'reports/html', open: 'never' }],
['junit', { outputFile: 'reports/junit/results.xml' }],
['json', { outputFile: 'reports/json/results.json' }],
['./reporters/blendra-reporter.cjs', {}],
],The last entry is a reporter vendored into the repo. It sends the run to Blendra. The run appears in the project's run list next to runs from Blendra's cloud runner, and its run page names a cause for every failed scenario. Playwright Python exports carry a pytest reporter and Cypress exports a Cypress plugin; both export targets are in Beta, on the Pro+ and Team plans. The details are in the reporter docs.
It uses the "fail on purpose" pattern from the previous section, so know these rules before you add it to CI:
- It needs
BLENDRA_API_TOKEN. API tokens are on Pro, Pro+ and Team. Locally, a missing token prints a warning and skips the upload. In CI, a missing token fails a run whose tests passed. - With the token set, a run whose tests passed fails if Blendra does not confirm it received the results, for example on a 5xx answer, a network error or a timeout. A run whose tests failed keeps its failure.
- If you would rather CI never depends on Blendra, delete the reporter line. The suite then runs like any other Playwright project.
- It does not combine shards into one run yet. On GitHub Actions, matrix shards of one workflow run share a run id: the first shard to report creates the run, and every later shard's upload is refused, which fails a shard whose tests passed. On GitLab, each parallel job reports its own partial run. Keep a Blendra-reporting suite in one unsharded job (the exported GitHub workflow already is one job), or remove the reporter line from sharded jobs.
FAQ
How do I get JUnit and HTML reports from one run?
List both in the reporter array with their own output paths, as in the config above. From the command line, PLAYWRIGHT_JUNIT_OUTPUT_FILE=junit.xml npx playwright test --reporter=list,junit,html works too, but it replaces your config's reporters; --add-reporter=junit with the same variable keeps them. Without the variable, JUnit prints its XML to the log and writes no file.
Why does my JUnit report show a flaky test as passed?
Because JUnit has no flaky state. Playwright writes one <testcase> per test with its final verdict, and a test that passed on retry passed. Use the JSON report, the HTML report or a custom reporter with test.outcome() to track flaky tests.
Can I use third-party reporters like Allure?
Yes. A third-party reporter is an npm package you name in the reporter array, next to the built-in ones. The Playwright docs link to several open-source reporters, including Allure, ReportPortal and Monocart, which are also good examples to read before you write your own.
How do I get one HTML report from sharded runs?
Have each shard write a blob report, then run npx playwright merge-reports --reporter html on the collected zips. Traces come along, so a failure from any shard opens in the Trace Viewer from the merged report.