In Playwright, how do `test.step` and `testInfo.attach` change what the HTML report shows for a test?
answer
- Structure, not just more logging
- Named sections inside one test
- Evidence bound to the result
- Failure points at the section it broke in
- Content type decides how it renders
basics
~20 sSteps turn a test into named, timed sections in the report, and a failure is attributed to the step it broke in. Attachments record a named file or string on the test, rendered inline for images and text.
solid answer
~40 s`await test.step('approve the payroll run', async () => { ... })` wraps part of a test in a named unit. In the report each step becomes a timed row under its test, nested when steps nest, and the error is attributed to the failing step rather than to the test as a whole - so a reader sees where it broke, not just that it broke. `testInfo.attach(name, { path })` or `{ body }` records evidence against the test; Playwright copies it at the moment of the call, and the report lists it by name, rendering images and text inline and offering anything else as a file. `contentType` decides that rendering and is inferred from the file extension when you pass a path. Both travel to every reporter, not just html.
code
typescript · 15 linesimport { test, expect } from '@playwright/test';
test('March payroll run reconciles to the ledger', async ({ page }, testInfo) => {
await test.step('open the March payroll run', async () => {
await page.goto('/payroll/runs/2026-03');
await expect(page.getByRole('heading', { name: 'March 2026' })).toBeVisible();
});
const totals = await test.step('read the on-screen totals', async () => {
return page.getByTestId('run-totals').innerText();
});
await testInfo.attach('run-totals.txt', { body: totals, contentType: 'text/plain' });
expect(totals).toContain('Net pay');
});go deeper
Know the two calls: test.step names a section of a test, testInfo.attach records a named file or string against it. Both show up in the HTML report without extra configuration.
Explain that a step is the same code in the same worker with a label and a duration, that failures are attributed to the step, and that contentType decides whether an attachment renders inline or downloads.
Design the test so a failure is diagnosable without a rerun: steps that name business actions, and attachments captured at the moment the state existed rather than reconstructed afterwards.
Set the house convention for what gets a step and what gets attached, and weigh report size against triage value - a suite that attaches everything makes artifacts nobody can store or search.
## What a step is `test.step(title, body)` wraps a slice of a test in a named, timed unit: ```ts await test.step('approve the March payroll run', async () => { await page.getByRole('button', { name: 'Approve' }).click(); await expect(page.getByText('Approved')).toBeVisible(); }); ``` The body runs exactly as it would have run unwrapped — same worker, same page, same timeout budget for the test — and `test.step` returns whatever the callback returns, so a step can produce a value the rest of the test uses. Steps nest, so a helper that itself uses `test.step` shows up as a sub-tree. ## What the report does with steps - Each step becomes a named row under its test, with its own duration, so a reader can see which part of a twelve-minute payroll test consumed the time. - A failure is attributed to the step it happened in, which turns "this test failed" into "approval failed after the run loaded". - Nested steps render as a tree, so a page-object method that wraps its own actions reads as one collapsible unit. - `test.step(title, body, { box: true })` boxes a helper: errors from inside are reported at the step's own location rather than deep inside the helper, which is what you want for shared utilities. - The `list` reporter's `printSteps` option prints the same titles in the terminal, so steps are not only an HTML-report feature. ## Attachments: evidence that travels with the result `testInfo.attach(name, options)` records a file or a buffer against the current test. It takes either `{ path }` for a file on disk or `{ body }` for a string or `Buffer`, plus an optional `contentType`. Inside a test you can reach the same object with `test.info().attach(...)`; in a fixture or a hook the `testInfo` argument is already there. Playwright copies the attachment into the run's output at the moment of the call, so the file must exist then — attaching a path that a later cleanup step deletes is fine, attaching one that does not exist yet is not. The attachment then appears by name on that test in the HTML report: images render inline, text is shown, anything else is offered as a file to open. ## Content type decides how it renders | Call | Recorded as | Rendered as | | --- | --- | --- | | `{ body: 'net pay 12,430.00', contentType: 'text/plain' }` | inline text | shown in the report | | `{ path: 'diff.png' }` | a copied file, type inferred from the extension | an inline image | | `{ path: 'gross-to-net.csv' }` | a copied file | a downloadable attachment | | `{ body: buf }` with no `contentType` | a binary blob | a downloadable attachment | Omitting `contentType` is usually fine for a path — it is inferred from the extension — but a string body without one is treated as plain text and a `Buffer` body as opaque bytes, which is why a JSON payload attached as a `Buffer` renders as a download instead of readable text. ## Why this beats `console.log` 1. Console output is a flat stream shared by every test in the worker; an attachment is bound to the test and the attempt that produced it. 2. Console output has no name, no type and no size limit anyone enforces; an attachment has all three and is addressable in the report. 3. Steps give the stream a shape, so the reader does not have to reconstruct the order of operations from timestamps. ## Where else they surface Steps and attachments are not html-only. They are part of the result model every reporter sees: the `json` reporter includes them in its output, the `junit` reporter can embed attachments as a property, and a custom reporter reads `result.attachments` and `result.steps` directly. Two costs are worth naming: very large attachments inflate the report folder the job has to archive, and a step wrapped around a single line adds noise without adding structure.
- How do you attach evidence from a fixture or an afterEach hook rather than from the test body?Both receive `testInfo`, so call `testInfo.attach(...)` there directly; inside a test body without that argument, `test.info().attach(...)` reaches the same object. Fixture teardown is the natural place for evidence you want on every test in a suite, since the test's own code does not have to remember to record it.
- What does the box option on test.step change about a failure?`test.step(title, body, { box: true })` reports an error thrown inside the step at the step's own location rather than deep inside the helper that threw it. It is meant for shared page-object helpers, where the useful location is the call site in the test, not a line inside a utility file.
Steps are chapter headings in the test's story and attachments are the appendix: the reader can see which chapter failed and still open the exhibit that proves it.
saying these in an interview costs you the question
- Thinks test.step retries or isolates the code it wraps
- Uses console.log for evidence instead of an attachment
- Attaches a file path that does not exist yet
- Believes attachments only show up in the trace viewer
- Wraps every single line in a step, adding noise not structure