ScreenshotNeo

BlogHow-to

How to Use `test.step` in Playwright

Learn how to use Playwright test.step for readable reports, nested actions, attachments, conditional skips, timeouts, and custom reporters.

By the ScreenshotNeo team1 October 20268 min read

test.step creates a named, reportable section inside a Playwright Test. Call and await it with a descriptive title and an asynchronous callback:

import { test, expect } from '@playwright/test';

test('checkout', async ({ page }) => {
  await test.step('Open the product page', async () => {
    await page.goto('/products/123');
  });

  await test.step('Add the product to the cart', async () => {
    await page.getByRole('button', { name: 'Add to cart' }).click();
    await expect(page.getByRole('status')).toContainText('Added');
  });
});

The named actions appear in Playwright reports and traces. Steps are optional for execution, but they make a test’s intent and failure location much easier to understand. See the official Playwright Test API for the current signature and version details.

1. Create your first named steps

Use short, action-oriented titles that describe a meaningful operation or checkpoint. Keep assertions with the action they validate.

import { test, expect } from '@playwright/test';

test('user can sign in', async ({ page }) => {
  await test.step('Open the sign-in page', async () => {
    await page.goto('/sign-in');
  });

  await test.step('Submit valid credentials', async () => {
    await page.getByLabel('Email').fill('alex@example.com');
    await page.getByLabel('Password').fill('correct-horse-battery-staple');
    await page.getByRole('button', { name: 'Sign in' }).click();
  });

  await test.step('Verify the account dashboard', async () => {
    await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  });
});

2. Return a value from a step

The callback’s return value becomes the result of test.step. Await the step when a later action needs that value.

const username = await test.step('Choose an account', async () => {
  return 'alex';
});

expect(username).toBe('alex');

This is useful for keeping setup logic visible in the report while still composing values in the test.

3. Nest steps for larger workflows

A step can contain other steps. Use a parent for a business-level phase and children for its concrete actions.

await test.step('Complete checkout', async () => {
  await test.step('Review cart', async () => {
    await expect(page.getByTestId('cart-total')).toHaveText('$42.00');
  });

  await test.step('Enter shipping address', async () => {
    await page.getByLabel('Address').fill('1 Market Street');
    await page.getByLabel('City').fill('San Francisco');
  });

  await test.step('Place order', async () => {
    await page.getByRole('button', { name: 'Place order' }).click();
  });
});

Nested steps give the HTML report and trace viewer a hierarchy that mirrors the workflow. Avoid wrapping every locator call in its own step; that produces noise without adding useful context.

4. Configure step options

The documented signature is test.step(title, body, options?). Options solve different reporting and control problems:

Option Purpose Availability
box Points an error at the step call site instead of an internal helper line. Added in Playwright 1.39
location Supplies a custom source location shown in reports and the trace viewer. Added in 1.48
timeout Limits how long this individual step may run. The default is 0, meaning no step-specific timeout. Added in 1.50
params Adds serializable parameters for reporters and the trace viewer. Added in 1.63
subtitle Adds a secondary label beside the step title in reports and traces. Added in 1.63

Check the version installed by your project before using recently added options.

Box helper errors at the call site

When a reusable helper fails, box: true makes the report point to the line that invoked the helper.

async function addProduct(page, name) {
  await page.getByRole('button', { name: `Add ${name}` }).click();
}

test('cart', async ({ page }) => {
  await test.step('Add the keyboard', () => addProduct(page, 'keyboard'), {
    box: true,
  });
});

Add a timeout to one slow step

await test.step('Generate export', async () => {
  await page.getByRole('button', { name: 'Export' }).click();
  await expect(page.getByText('Export ready')).toBeVisible();
}, { timeout: 30_000 });

A step timeout does not replace Playwright’s test, action, or assertion timeouts. Configure those separately when needed.

Add report context with params and subtitle

await test.step('Open invoice', async () => {
  await page.goto(`/invoices/${invoiceId}`);
}, {
  subtitle: 'Billing flow',
  params: { invoiceId },
});

Keep parameters serializable. Do not put passwords, access tokens, or other secrets into titles, subtitles, or params because reporters and traces may persist them.

Set a displayed source location

await test.step('Validate totals', async () => {
  await expect(page.getByTestId('total')).toHaveText('$42.00');
}, {
  location: { file: 'tests/checkout.spec.ts', line: 42, column: 3 },
});

5. Use TestStepInfo for skips and attachments

The callback may receive a TestStepInfo object. It supports conditional skipping and step-scoped attachments. The TestStepInfo API documents both methods.

Conditionally skip a step

test('desktop navigation', async ({ page }, testInfo) => {
  const isMobile = testInfo.project.name === 'mobile';

  await test.step('Check desktop-only control', async step => {
    step.skip(isMobile, 'Not present in the mobile layout');
    await expect(page.getByRole('button', { name: 'Desktop action' })).toBeVisible();
  });
});

Use step.skip(condition, description) when only one part of a test is inapplicable. Use a test or project-level skip when the entire test should not run.

Attach a file to the step

await test.step('Save downloaded report', async step => {
  const downloadPromise = page.waitForEvent('download');
  await page.getByRole('button', { name: 'Download report' }).click();
  const download = await downloadPromise;
  const path = await download.path();

  if (path) {
    await step.attach('report', {
      path,
      contentType: 'application/pdf',
    });
  }
});

A step attachment is attributed to that step. testInfo.attach() instead stores an attachment at the test level.

6. View steps in reports and traces

Run the test with Playwright Test, then open the HTML report:

npx playwright test
npx playwright show-report

The test detail view shows the step hierarchy, durations, errors, and attachments. Traces also include the named steps, which helps correlate browser actions with your test’s business flow. The official running and debugging guide covers report and trace workflows.

Observe steps in a custom reporter

Custom reporters can implement onStepBegin and onStepEnd. Playwright emits these events while the test runs, before onTestEnd.

class StepReporter {
  onStepBegin(test, result, step) {
    if (step.category === 'test.step') {
      console.log(`BEGIN ${step.title}`);
    }
  }

  onStepEnd(test, result, step) {
    if (step.category === 'test.step') {
      console.log(`END ${step.title}`);
    }
  }
}

module.exports = StepReporter;

Configure it in playwright.config.ts:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  reporter: [['list'], ['./step-reporter.js']],
});

See the Reporter API and test configuration reference for reporter configuration.

  • Use a verb and an object: Submit payment, Verify order status.
  • Group related browser operations under one meaningful step.
  • Keep assertions inside the step that performs the action they validate.
  • Use nested steps for phases such as setup, checkout, and verification.
  • Use box: true on public helper calls when the invocation is more useful than the helper internals.
  • Keep titles and params free of secrets and unstable data unless that data is safe to retain in reports.
  • Do not use steps as a replacement for fixtures, page objects, retries, or test isolation.

8. Troubleshooting

The report does not show my steps

Confirm that the test runs through Playwright Test and that you are opening the report generated by that run. A plain script that launches a browser does not automatically create Playwright Test step entries.

The failure points inside a helper

Wrap the helper invocation with { box: true }. This changes the reported error location to the step call site. It does not change the underlying failure.

An option is rejected or ignored

Check the installed Playwright version. The reference lists box from 1.39, location from 1.48, timeout from 1.50, and params and subtitle from 1.63. Upgrade Playwright deliberately and verify the project lockfile if the option is unavailable.

A step times out unexpectedly

Inspect whether the timeout came from the step, an action, an assertion, navigation, or the overall test. Increase only the limit that matches the slow operation, and prefer waiting for a reliable locator or state over adding a long fixed delay.

Nested steps are missing

Make sure every nested call is awaited. If an asynchronous test.step promise is not awaited, the parent may finish before its child is recorded and failures can be detached from the intended hierarchy.

Attachments are not visible

Attach through the step argument for step-level attribution, provide a valid path or buffer, and inspect the HTML report for the completed test. Use testInfo.attach() only when test-level placement is intended.

9. Performance, reliability, and cost

Named steps add report metadata and a small amount of bookkeeping, but they do not make the browser action itself faster. The main performance cost remains navigation, rendering, network activity, and assertions. Avoid thousands of tiny steps in a tight loop if report size and readability matter.

For reliable tests, make each step represent a deterministic state transition. Prefer locator assertions and explicit readiness conditions over arbitrary sleeps. Step names do not add retries; configure retries and isolation in Playwright Test when the test environment requires them.

Playwright Test and its HTML reporter are local tooling. Your practical cost is the CI runtime and stored report or trace artifacts. Retain traces and attachments according to your repository’s storage policy, especially when they may contain user data.

10. Or skip the browser setup

If your goal is to capture a page image rather than run an interaction test, ScreenshotNeo provides a single screenshot API request. Its browser handles cookie and consent banners, newsletter popups, and chat widgets before the capture; bot checks, blank pages, failed loads, timeouts, and cache hits are not billed. Responses identify the result with X-Page-Verdict and X-Billed headers. An MCP server also lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf.

See the ScreenshotNeo API documentation for authentication and options.

cURL

curl -G 'https://api.screenshotneo.com/v1/shot' \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

Python

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)

Node.js

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 fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

ScreenshotNeo includes full-page and element capture, device presets, custom viewports, dark mode, retina scale, PDF output, custom CSS and JavaScript, waits, request blocking, headers, cookies, user agents, caching, signed links, asynchronous jobs, bulk capture, and a usage API. The Free plan includes 1,000 screenshots per month without a card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

FAQ

Does every Playwright operation need a step?

No. Add steps around actions or checkpoints that help someone understand the test report.

Can a step contain another step?

Yes. Nested steps are supported and appear hierarchically in reports and traces.

Can a step return data?

Yes. The awaited result is the callback’s return value.

What is the difference between box and location?

box changes where an error points. location changes the source location displayed for the step.

Where should I put a screenshot attachment?

Use step.attach() when it belongs to one step, or testInfo.attach() when it belongs to the whole test.