skip to content

A Playwright test writes a debug screenshot to shots/map.png, yet the run's report shows no image; why?

level: seniorimportance: should knowfreq 40%

answer

  1. A file on disk is not a result
  2. Two bugs: visibility and location
  3. Ask the runner for the path
  4. One folder per test, per attempt
  5. Attach before the test ends

basics

~20 s

Writing a file is not the same as reporting one. Playwright shows attachments, so the capture must be attached with testInfo.attach, and its path should come from testInfo.outputPath so it is unique per test and per attempt.

solid answer

~40 s

Two things are wrong. First, a report renders **attachments**, and a file written to an arbitrary path is not one -- call `testInfo.attach('driver map', { path, contentType: 'image/png' })`, or attach the buffer directly with `{ body: await page.screenshot(), contentType: 'image/png' }`. Second, `shots/map.png` is a fixed path outside the run's output directory: parallel workers and retries all write the same file, it is never cleaned between runs, and nothing collects it. Use `testInfo.outputPath('map.png')`, which returns a path inside this test's own folder under `outputDir` and creates the directory for you, so each test and each retry gets its own file. Playwright copies an attached file somewhere reporters can read it, so you may delete your original afterwards. Attachments must be added before the test finishes.

code

typescript · 8 lines
typescript
import { test } from '@playwright/test';

test('driver map appears once the order is picked up', async ({ page }, testInfo) => {
  await page.goto('/orders/8842');
  const shot = testInfo.outputPath('driver-map.png');
  await page.getByTestId('driver-map-card').screenshot({ path: shot });
  await testInfo.attach('driver map', { path: shot, contentType: 'image/png' });
});

go deeper

for a junior

Recall that a report shows attachments, so a captured image must be attached with testInfo.attach, and that testInfo.outputPath gives you a path inside the test's own output folder.

for a middle

Explain why a fixed relative path breaks under parallel workers and retries, and how the per-test, per-attempt output directory plus cleaning solves collisions and stale files.

for a senior

Diagnose the two failures separately, wire capture and attachment into a fixture teardown so it applies without editing tests, and keep attachment size under control.

for a principal

Decide what evidence a suite always produces versus produces on failure, and how much report weight the organisation will carry before nobody opens it.

## Two independent bugs in one line The symptom is "my screenshot is missing", but there are two separate failures hiding in `await page.screenshot({ path: 'shots/map.png' })`: 1. **Nothing told the run about the file.** Reports and reporters display *attachments*. A file on disk that nobody attached is invisible to them, however correct the image is. 2. **The path is wrong for a parallel, retrying test run.** It is fixed, shared and outside the run's managed output, so it collides and is never cleaned. Fixing only the first leaves you with a report that links a file three workers overwrote. ## Where a test's files belong: testInfo.outputPath `testInfo.outputPath(...segments)` returns a path inside **this test's own directory** under `outputDir` and ensures the directory exists. That directory is derived from the spec file, the test title and the project, with a retry suffix on later attempts, which buys three things at once: - **Uniqueness.** Two workers running different tests cannot collide; nor can attempt one and attempt two of the same test. - **Cleaning.** The test's folder is cleared before the test runs, so you never read a stale image from yesterday's run and mistake it for today's. - **Colocation.** Your capture sits beside the automatic failure screenshot, the video and the trace, which is where anyone triaging the order tracker will already be looking. Inside a test, reach it as `test.info().outputPath('driver-map.png')`; inside a fixture or a hook, the `testInfo` argument is passed to you. ## Making it visible: testInfo.attach `testInfo.attach(name, options)` records the file against the test result. Two shapes: ```ts // by path await test.info().attach('driver map', { path: test.info().outputPath('driver-map.png'), contentType: 'image/png', }); // or straight from the buffer, no file needed await test.info().attach('order summary', { body: await page.getByTestId('order-summary').screenshot(), contentType: 'image/png', }); ``` Points that matter in practice: - Playwright copies an attached file into a place reporters can read, so you may delete your original after the `attach` call resolves. - The `name` is what a reader sees, so name it for the thing photographed, not `screenshot-1`. - `contentType` decides whether the image renders inline or appears as a download; PNG buffers should be declared `image/png`. - Attachments must be added **before the test finishes**. Attaching from `afterEach` or from the teardown half of a fixture is fine; attaching after the runner has closed the test is not. ## The corrected version 1. Capture into `testInfo.outputPath('driver-map.png')`. 2. Attach it with a descriptive name and content type. 3. Do both at the moment of interest -- typically when a soft check has already failed, or unconditionally at a step whose visual state you always want. ```ts test('driver map appears once the order is picked up', async ({ page }, testInfo) => { await page.goto('/orders/8842'); const map = page.getByTestId('driver-map-card'); const shot = testInfo.outputPath('driver-map.png'); await map.screenshot({ path: shot }); await testInfo.attach('driver map', { path: shot, contentType: 'image/png' }); }); ``` ## Why not just rely on the automatic failure screenshot The runner's own capture is taken after the test body ends, so it shows the end state of the whole page. A manual capture is how you record a *moment*: the map card immediately after the status changed to "picked up", before a re-render replaced it. The two are complements, and both end up as attachments on the same result. ## Common traps - Writing to a relative path from the process working directory, which is the repository root, not the test's folder -- artefacts end up committed or, worse, silently shared between workers. - Attaching a path you then delete before the `attach` promise resolves. - Attaching a large full-page PNG on every test, which inflates the report until nobody opens it. - Assuming an attachment implies the run *uploaded* it anywhere; where artefacts are published from a pipeline is a separate concern from attaching them to a result.

  • How would you attach a screenshot from a fixture so every test in a project gets one on failure?
    Do it in the teardown half of an auto fixture: after `await use(...)`, check `testInfo.status !== testInfo.expectedStatus`, capture, and attach. The teardown still runs inside the test's lifetime, so attachments are accepted, and the capture path should come from `testInfo.outputPath` for the same uniqueness reasons.
  • When would you attach a buffer instead of a path?
    When you do not need the file on disk at all: `attach('summary', { body: await locator.screenshot(), contentType: 'image/png' })` skips writing and cleaning up entirely. Prefer a path when you also want the artefact sitting in the test's output folder beside the trace and video for someone digging by hand.

saying these in an interview costs you the question

  • Thinks any file written during a test appears in the report
  • Hardcodes a relative path shared by every worker
  • Assumes retries write to separate files automatically
  • Deletes the source file before the attach call resolves
  • Attaches after the test has finished and expects it to land
  • Attaches full-page images from every test regardless of outcome