How to Take a Playwright Screenshot After a Test Passes
Capture a screenshot only when a Playwright test passes, attach it to reports, save files safely in CI, and troubleshoot common edge cases.
Use test.afterEach() and check testInfo.status === 'passed'. The hook runs after the test body and assertions finish while the page is still available. You can attach the screenshot to the Playwright report or save it to the test’s isolated output directory.
Capture and attach a screenshot after every passing test
This TypeScript example attaches a PNG buffer to reporters that support test attachments:
import { test } from '@playwright/test';
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status !== 'passed') return;
const screenshot = await page.screenshot();
await testInfo.attach('passed-screenshot', {
body: screenshot,
contentType: 'image/png',
});
});
TestInfo contains information about the currently running test, including its final status and per-test output paths. Some reporters show test attachments. See the Playwright TestInfo API and reporter documentation.
Save a file instead of attaching it
Use testInfo.outputPath() when another CI step, archive job, or script needs a real file:
import { test } from '@playwright/test';
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status !== 'passed') return;
await page.screenshot({
path: testInfo.outputPath('passed.png'),
fullPage: true,
});
});
The output path is created under Playwright’s test-results structure and includes the test’s isolation rules, which helps avoid filename collisions when workers run tests in parallel.
Choose an attachment or a file
| Requirement | Use | Reason |
|---|---|---|
| Show the image in an HTML or custom report | testInfo.attach() |
The reporter receives an association between the test and the image. |
| Upload or process it in a later CI step | path: testInfo.outputPath(...) |
Downstream tools can read a normal file. |
| Post-process pixels before reporting | const buffer = await page.screenshot() |
You can inspect or transform the buffer before attaching it. |
For an attachment, body and path are mutually exclusive. Await both the screenshot and testInfo.attach() so Playwright finishes copying the attachment before teardown.
Screenshot options that matter after a pass
Viewport versus full page
// Current viewport only (default)
await page.screenshot({ path: testInfo.outputPath('viewport.png') });
// Includes the full scrollable page
await page.screenshot({
path: testInfo.outputPath('full-page.png'),
fullPage: true,
});
A viewport shot is compact and represents what a user sees at the current scroll position. fullPage: true can produce a tall image and may include content revealed by scrolling.
PNG, JPEG, and quality
await page.screenshot({
path: testInfo.outputPath('passed.jpg'),
type: 'jpeg',
quality: 85, // JPEG only
});
PNG is lossless and is usually the safest report artifact. JPEG can reduce storage for photographic pages; its quality option does not apply to PNG.
Capture one element
const chart = page.locator('[data-testid="result-chart"]');
await chart.screenshot({
path: testInfo.outputPath('chart.png'),
});
Element screenshots are useful when the full page contains dynamic ads, timestamps, or unrelated content. Wait for the element to be visible and stable before capturing it.
Mask dynamic regions
await page.screenshot({
path: testInfo.outputPath('passed.png'),
mask: [page.locator('[data-testid="clock"]')],
maskColor: '#888888',
});
Masking prevents changing clocks, avatars, or generated identifiers from making otherwise successful screenshots differ between runs.
Use a reusable fixture or project-wide hook
Put the hook in a shared setup file when every test should produce a passing screenshot:
// tests/fixtures.ts
import { test as base } from '@playwright/test';
export const test = base.extend({});
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status !== 'passed') return;
await testInfo.attach('passed-screenshot', {
body: await page.screenshot({ fullPage: true }),
contentType: 'image/png',
});
});
export { expect } from '@playwright/test';
Import test from this file in your test modules. If only some suites need screenshots, keep the hook next to those tests or add an opt-in fixture instead.
Handling retries, skips, and timeouts
testInfo.status is the final status for the current attempt. The guard excludes failed, skipped, timed-out, and interrupted attempts. With retries enabled, a test that fails once and passes on a retry can produce a screenshot for the successful attempt. If you need only the first attempt or only the final overall result, also inspect testInfo.retry and your project’s retry policy.
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status !== 'passed') return;
if (testInfo.retry > 0) return; // optional: first attempt only
await page.screenshot({ path: testInfo.outputPath('passed.png') });
});
A skipped test normally has no usable page state, so returning before capture is intentional.
Pages that close themselves
If the test closes the page or context, the afterEach fixture may no longer be capturable. Capture before closing it, or keep a separate page for the final artifact:
test('exports a report', async ({ browser }, testInfo) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/report');
// assertions...
await page.screenshot({ path: testInfo.outputPath('passed.png') });
await context.close();
});
When possible, let Playwright manage the standard page fixture and avoid closing it manually.
CI and parallel execution checklist
- Use
testInfo.outputPath(), never a shared filename such asscreenshots/passed.png. - Await every screenshot and attachment operation.
- Upload the Playwright test-results directory as a CI artifact if you save files.
- Configure the reporter you actually use; an attachment is only visible when that reporter supports attachments.
- Keep screenshots opt-in for very large suites if storage or report size matters.
- Set a stable viewport, timezone, locale, and reduced-motion preference when visual consistency matters.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| No screenshot is created | The status guard is not seeing passed, or the hook is not loaded. |
Confirm the test imports the file containing the hook and log testInfo.status temporarily. |
| Attachment is missing from the report | The selected reporter does not display attachments, or the attach call was not awaited. | Use a reporter with attachment support and await testInfo.attach(). |
page.screenshot throws because the page is closed |
The test closed its page or context before afterEach. |
Capture before closing, or use a separate context/page fixture. |
| Images differ between successful runs | Animations, clocks, network content, or responsive layout changed. | Freeze or mask dynamic elements, disable animations with CSS, and use a fixed viewport. |
| Full-page capture is unexpectedly tall | fullPage: true includes the complete scrollable document. |
Use viewport capture or element capture when only a region is needed. |
| CI runs out of storage | Every passing test creates a potentially large artifact. | Capture only selected suites, use JPEG where acceptable, or retain artifacts only for important jobs. |
Performance, reliability, and cost
A screenshot adds browser rendering and image encoding work after each passing test. Full-page images and large device scale factors require more memory than viewport PNGs. Keep the capture after the last assertion, avoid unnecessary screenshots in large smoke suites, and prefer element captures when they answer the debugging or audit question.
For reliable artifacts, wait for the state you want before the final assertion, use deterministic test data, and mask volatile content. A passing assertion does not guarantee that an animation has stopped or that late network content is visually complete.
Playwright itself does not charge per screenshot. Your practical costs are CI time, storage, report size, and artifact retention. If screenshots are generated outside the test browser, a screenshot API may charge per successful capture and can avoid maintaining browser infrastructure.
Or skip the browser setup
ScreenshotNeo provides a website screenshot API and MCP server. It accepts a URL and returns PNG, JPEG, WebP, or PDF. Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and the response identifies the page verdict and billing result in headers.
See the ScreenshotNeo API documentation for all options.
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://stripe.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const bytes = new Uint8Array(await res.arrayBuffer());
// Write bytes with your runtime's file API.
ScreenshotNeo also supports full-page capture, CSS element selection, dark mode, device presets, custom viewport and retina scale, custom CSS and JavaScript, click and wait actions, blocked resource types, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, selectable-TTL caching, signed image links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, a usage API, and an OpenAPI specification. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients.
There is a free plan with 1,000 shots per month and no card. Paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
FAQ
Should I capture in afterEach or after the last assertion?
Use afterEach when you want a consistent rule and access to the final status. Capturing after the last assertion works only if every test follows that convention.
Can I attach a JPEG?
Yes. Pass type: 'jpeg' and a quality value to page.screenshot(), then set contentType: 'image/jpeg' when attaching the buffer.
Why capture successful tests at all?
Passing screenshots document the rendered state, provide visual evidence for audits, and make report review faster when a test verifies a workflow or page layout.
Where does Playwright store an attached image?
Playwright copies an attached buffer to a reporter-accessible location after the awaited attachment call. The exact presentation depends on the reporter.


