Capturing Screenshots and Video on Test Failure
When a test fails on CI that nobody was watching, the only way to understand it is the evidence the run left behind. Playwright can record a screenshot, a video, and a full trace for every test, but recording all three on every run is slow and fills disk, so the practical pattern is to capture them only when a test fails. Three use options — screenshot: 'only-on-failure', video: 'retain-on-failure', and trace: 'on-first-retry' — give you rich forensic artifacts at almost zero cost on the happy path. This page belongs to Reporters & Test Artifacts, part of the Debugging & Test Observability guide, and walks through configuring, storing, and attaching these artifacts.
Root cause: capture is tied to outcome, not to the run
The reason artifact configuration confuses people is that the three recorders work on different clocks. A screenshot is a single moment — Playwright takes it after the test body has finished and the outcome is known, so only-on-failure genuinely costs nothing on a green run. Video is the opposite: the browser has to be recording from the first navigation, because you cannot go back and film a failure that already happened, so retain-on-failure records everything and then deletes the files for tests that passed. A trace is somewhere in between — it is cheap enough to arm at the start of an attempt but expensive enough that you would rather arm it only on the attempt you are going to read, which is why on-first-retry exists.
Once you internalise that split — decide-after for screenshots, record-then-discard for video, arm-per-attempt for traces — every mode name in the config reads sensibly, and the disk and runtime behaviour of your suite becomes predictable instead of surprising.
The three artifact options and what each costs
All three are set under use in playwright.config.ts, and each accepts a mode that ties capture to the outcome:
screenshot: 'only-on-failure'— a single image saved when, and only when, a test fails. AddfullPage: truevia the object form if you need the whole scroll height rather than the viewport.video: 'retain-on-failure'— Playwright records every test but discards the recording for passing tests, keeping only the videos of failures. The file is finalised when the context closes, not when the test ends.trace: 'on-first-retry'— a complete replayable trace recorded on the second attempt of a test that failed once, which is exactly the run you want to inspect in the Trace Viewer & Debugging guide.
The configuration
import { defineConfig } from '@playwright/test';
export default defineConfig({
// Retries are needed for trace: 'on-first-retry' to ever trigger.
retries: process.env.CI ? 2 : 0,
// Every artifact lands under this folder, one sub-folder per failing test.
outputDir: 'test-results',
use: {
// Save one image only when a test fails.
screenshot: 'only-on-failure',
// Record every test but keep the video only for failures.
video: 'retain-on-failure',
// Record a full trace on the first retry of a failed test.
trace: 'on-first-retry',
},
// The HTML reporter attaches all captured artifacts automatically.
reporter: [['html', { open: 'never' }]],
});
Because trace: 'on-first-retry' only fires when a test is retried, it does nothing unless retries is above zero — the retry and timeout settings in Configuring Retries and Timeouts for Stable CI are a prerequisite. If you already keep a layered config with shared defaults, fold these three options into the base use block described in Playwright Config & Fixtures so every project inherits them.
What the recorders do across a retry
Following one failing test through both attempts makes the timing concrete. On attempt one the video recorder is running from the moment the context opens; the trace recorder is not armed at all, because no retry has happened yet. The test fails, Playwright takes the screenshot, closes the context, flushes the video file, and schedules the retry. On attempt two the trace recorder starts with the context, the video recorder runs again, and if the test fails a second time you end up with two screenshots, two videos, and one trace.
That asymmetry explains a common complaint: the video shows the failure but the trace does not exist, because the test failed once and was not retried. If you need a trace on the very first failure — for a suite where retries are deliberately disabled, or where the failure does not reproduce — use trace: 'retain-on-failure' instead and accept the extra recording cost on every test.
Where the artifacts land on disk
Everything is written under outputDir, which defaults to test-results and is wiped at the start of each run. Playwright creates one sub-folder per failing test, named from the file, the test title, and the project, so parallel workers never collide. Inside it you get test-failed-1.png, video.webm, and trace.zip — the numeric suffix increments per attempt, so a retried test has test-failed-1.png and test-failed-2.png side by side.
The HTML report is a separate folder (playwright-report by default) that references those files by path. Upload both from your pipeline, or pass attachmentsBaseURL when you host them apart — the sharding setup in Running Playwright Tests in GitHub Actions with Sharding covers merging reports from several machines.
Attaching custom artifacts
Beyond the automatic captures, you can attach your own files — a downloaded export, an API response, a manual screenshot — to the report with testInfo.attach(). They appear inline in the HTML report next to the test:
import { test, expect } from '@playwright/test';
test('attaches a manual screenshot to the report', async ({ page }, testInfo) => {
await page.goto('/dashboard');
// Capture a buffer rather than writing to disk first.
const shot = await page.screenshot();
// Attach it so it shows up inline in the HTML report for this test.
await testInfo.attach('dashboard-state', { body: shot, contentType: 'image/png' });
await expect(page.getByRole('heading', { name: 'Overview' })).toBeVisible();
});
Attachments are the extension point the whole artifact system is built on: the automatic screenshot, video, and trace are themselves attachments with reserved names, which is why a custom reporter can read them the same way. If you emit artifacts into another system — a dashboard, a bug tracker — read them off result.attachments as described in Writing a Custom Playwright Reporter.
Step-by-step setup
- Enable retries so traces can trigger. Set
retries: process.env.CI ? 2 : 0, becausetrace: 'on-first-retry'records nothing unless a failed test is actually retried. - Set screenshot to only-on-failure. Add
screenshot: 'only-on-failure'underuseso an image is saved for failures and nothing is written for passing tests. - Set video to retain-on-failure. Add
video: 'retain-on-failure'so every test is recorded but only failure videos are kept, giving you the full sequence that led to the break without storing thousands of green recordings. - Set trace to on-first-retry. Add
trace: 'on-first-retry'so the second attempt of a failing test produces a complete replayable trace, the single most useful artifact for diagnosis. - Pin outputDir so CI can collect the files. Set
outputDir: 'test-results'explicitly and make sure the path is not inside a folder your pipeline cleans between steps, otherwise the report will link to files that no longer exist. - Use the HTML reporter to surface artifacts. Configure
['html', { open: 'never' }]so all captured screenshots, videos, and traces attach to their tests automatically and the report does not pop open in CI. - Attach any custom evidence with testInfo.attach(). For files Playwright does not capture by default, call
await testInfo.attach(name, { body, contentType })inside the test so the artifact appears inline in the report. - Upload the report and the results folder from CI. Publish
playwright-reportandtest-resultsas build artifacts with a retention window — a week is usually enough — following the pipeline patterns in CI/CD Integration.
Troubleshooting variants
The report opens but every video and trace link is broken
The report folder was uploaded without test-results, so the HTML references files that were never published. Upload both directories from the same job, or unpack them into the same parent folder before opening the report locally. When the report is served from object storage and the artifacts live elsewhere, set attachmentsBaseURL on the HTML reporter so the links resolve against the correct host.
Videos are enormous and the runner disk fills up
retain-on-failure still records every test, and on a large suite the temporary files can outgrow the runner before the passing recordings are deleted. Shrink the frame size with the object form — video: { mode: 'retain-on-failure', size: { width: 800, height: 600 } } — or scope recording to a single project so only the browser you actually debug in is filmed. The same tuning matters for scraping-style runs where hundreds of contexts open in sequence.
A screenshot is captured but shows a blank or half-rendered page
The image is taken at the moment the test is declared failed, which is often mid-navigation or mid-animation. That is accurate evidence, not a bug: the page really was in that state when the assertion timed out. Read the video or the trace for the frames leading up to it, and if animations make the frame useless, disable them for capture the way baseline snapshots do in Masking Dynamic Regions in Snapshots.
Verification
Confirm capture works four ways. First, deliberately fail a test locally and check the test-results folder — a screenshot and video should appear for that test and for no passing test. Second, open the HTML report and verify the failed test shows its image, video, and a trace launcher inline. Third, force a retry on CI and confirm the trace from the second attempt downloads and opens in Analyzing Test Failures with Playwright Trace Viewer, where the network and console panes are described in Reading Network and Console Tabs in Traces. Fourth, run the suite green and confirm test-results is empty afterwards — leftover files there mean a mode is set to on rather than a failure-scoped value.
Once the evidence pipeline is reliable, it changes how you triage: a failure report that arrives with a frame, a film, and a replay is usually diagnosable without reproducing anything locally, which is the foundation of the workflow in Detecting and Fixing Flaky Playwright Tests.
Frequently Asked Questions
Why is no trace captured even though I set trace on-first-retry?
The on-first-retry mode only records a trace when a failed test is retried, so it never fires if retries is zero. Set retries above zero in your config — commonly process.env.CI ? 2 : 0 — or switch to trace: 'retain-on-failure' if you want a trace on the very first failure without a retry.
Does video: 'retain-on-failure' slow down passing tests?
Playwright records every test to produce the failure videos, so there is a small overhead even on passing runs, but the recording is discarded the moment a test passes. The cost is modest and usually worth the evidence; if it matters for a huge suite, scope video capture to a single project rather than the whole run.
How do I attach a file Playwright does not capture automatically?
Use testInfo.attach(name, { body, contentType }) inside the test, passing a buffer or a path and the correct MIME type. The attachment appears inline in the HTML report next to that test, which is the way to surface downloaded exports, API payloads, or manual screenshots alongside the automatic artifacts.
Can I record video for one project only?
Yes. The use block is per project as well as global, so declare video: 'retain-on-failure' inside a single entry of the projects array and leave the top-level default at 'off'. Teams commonly film only the Chromium project and rely on traces for the other engines, which cuts recording overhead by roughly two thirds while keeping one watchable failure per test.
Why does the same test produce two screenshots?
The numeric suffix on test-failed-N.png counts attempts, so a test that failed, was retried, and failed again writes one image per attempt. Comparing the two is genuinely useful — an identical pair points at a real defect, while two different end states point at a timing problem worth chasing rather than a broken assertion.