Resource library

QA How-To

How to Use Playwright test.step (2026)

Learn how to use playwright test.step in 2026 with nested workflows, returned values, boxing, timeouts, attachments, conditional skips, and readable reports.

16 min read | 2,556 words

TL;DR

Wrap a coherent business operation in await test.step(title, async step => { ... }). Use returns, nesting, box, timeout, step.attach(), and step.skip() only when they make the report and failure ownership clearer.

Key Takeaways

  • Await every test.step() call because it returns a promise and propagates callback failures.
  • Use verb-led business titles rather than repeating clicks and fills already visible in reports.
  • Return values directly from step callbacks to keep multi-phase workflows typed.
  • Choose box: true when the helper call site is a more actionable error boundary.
  • Use the timeout option to bound one phase without assuming it extends the test timeout.
  • Attach sanitized evidence and skip only independent optional steps through TestStepInfo.

Playwright test.step groups related actions and assertions under a readable label in the HTML report, UI mode, and Trace Viewer. Call await test.step(title, async () => { ... }) inside a test, hook, fixture, or helper, and Playwright records the callback as one logical unit while preserving the actions nested inside it.

Use steps to describe business intent such as "Sign in as a member" or "Verify the saved invoice," not every click. In current Playwright Test, a step can return a value, contain nested steps, set a timeout, box error locations, attach evidence, and be skipped conditionally through the TestStepInfo callback argument.

TL;DR

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

test('reader opens the installation guide', async ({ page }) => {
  await test.step('Open the documentation', async () => {
    await page.goto('https://playwright.dev/docs/intro');
  });

  await test.step('Verify the guide heading', async () => {
    await expect(page.getByRole('heading', { name: 'Installation' }))
      .toBeVisible();
  });
});
Capability Syntax Practical purpose
Basic step test.step(title, body) Group actions by intent
Return data const value = await test.step(...) Pass created data to later work
Box errors { box: true } Point failures to the step call site
Step timeout { timeout: 10_000 } Bound one business operation
Attach evidence step.attach(name, options) Associate an artifact with one step
Conditional skip step.skip(condition, reason) Skip the current step, not the test

1. What Playwright test.step Does

test.step() creates a named report node around an asynchronous callback. Playwright actions, assertions, attachments, and nested steps executed inside the callback appear beneath that node. If the callback throws or an assertion fails, the step fails and the error propagates to the test unless your code catches it.

The method does not add synchronization by itself. It does not wait for a page to become ready, retry the callback, or isolate browser state. The actions and assertions inside retain their normal auto-waiting and timeout behavior. A step is primarily an execution and reporting boundary.

Every call should be awaited. Without await, the test can continue before the callback finishes, causing overlapping operations, lost errors, or teardown while the step is still active. The callback should also return or await its final asynchronous work.

Well-designed steps answer, "What business operation is happening?" A report with "Create customer," "Submit order," and "Verify confirmation" helps a triager locate the failing phase. A report with "Click button," "Fill input," and "Wait" merely repeats Playwright's already visible action details.

For complex suite setup, fixtures solve lifecycle and dependency problems that steps do not. The Playwright test fixtures override examples guide explains where setup ownership belongs. Use steps inside fixture or test workflows only when the report benefits from a named domain operation.

2. How to Add the First test.step

Import test and expect from @playwright/test, then wrap a coherent set of actions. This runnable sample uses the Playwright documentation site and asserts stable, user-visible content.

// docs.spec.ts
import { test, expect } from '@playwright/test';

test('finds the writing tests guide', async ({ page }) => {
  await test.step('Open Playwright documentation', async () => {
    await page.goto('https://playwright.dev/docs/intro');
    await expect(page).toHaveTitle(/Installation.*Playwright/);
  });

  await test.step('Navigate to the writing tests guide', async () => {
    await page.getByRole('link', { name: 'Writing tests' }).first().click();
    await expect(page).toHaveURL(/\/docs\/writing-tests/);
  });

  await test.step('Verify the guide content', async () => {
    await expect(page.getByRole('heading', { name: 'Writing tests' }))
      .toBeVisible();
  });
});

Each title starts with a verb and describes an observable goal. Assertions stay inside the relevant step so the report associates failure with the correct phase. The title does not include secrets or generated values that would make results hard to aggregate.

Run the test normally, then open the HTML report:

npx playwright test docs.spec.ts
npx playwright show-report

You can also inspect steps in UI mode with npx playwright test --ui. The report will include both your logical steps and lower-level Playwright actions, so there is rarely a need to wrap a single locator call unless the title adds domain meaning.

3. Nest Steps to Represent a Workflow

Steps can be nested. Use one outer step for a phase and a small number of inner steps for meaningful suboperations. Keep nesting shallow so reports remain scannable.

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

test('member completes profile setup', async ({ page }) => {
  await test.step('Complete onboarding', async () => {
    await test.step('Enter profile details', async () => {
      await page.goto('/onboarding/profile');
      await page.getByLabel('Display name').fill('Avery QA');
      await page.getByLabel('Time zone').selectOption('Asia/Kolkata');
    });

    await test.step('Choose notification preferences', async () => {
      await page.getByLabel('Weekly summary').check();
      await page.getByRole('button', { name: 'Continue' }).click();
    });
  });

  await test.step('Confirm onboarding is complete', async () => {
    await expect(page.getByRole('heading', { name: 'Welcome, Avery QA' }))
      .toBeVisible();
  });
});

This application-specific example uses real Playwright APIs. It should be adapted to matching labels and routes in the application under test.

Do not mirror every page object method with three levels of nested steps. If an outer test step calls page object methods that already create steps, inspect the resulting report before adding more structure. A flat sequence of five good steps is usually clearer than a tree of fifteen mechanical nodes.

Nested steps inherit the normal test timeout context. A timeout on an inner step fails that step and propagates through its parent. The parent duration includes the time spent in all children.

4. Return Values From Playwright test.step

test.step() returns the value returned by its callback. This makes a step useful for creation workflows without assigning through a mutable outer variable.

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

test('opens a newly created project', async ({ request, page }) => {
  const projectId = await test.step('Create project through the API', async () => {
    const response = await request.post('/api/projects', {
      data: { name: 'Release readiness' },
    });
    expect(response.ok()).toBeTruthy();

    const body = await response.json() as { id: string };
    return body.id;
  });

  await test.step('Verify project in the UI', async () => {
    await page.goto(`/projects/${projectId}`);
    await expect(page.getByRole('heading', { name: 'Release readiness' }))
      .toBeVisible();
  });
});

TypeScript infers projectId from the callback return type. If different branches return different types, declare an explicit type or simplify the callback. Avoid returning a locator that depends on a page which will be closed before the caller uses it.

Returning values also improves helper design:

async function createOrder(request: import('@playwright/test').APIRequestContext) {
  return test.step('Create test order', async () => {
    const response = await request.post('/api/orders', {
      data: { sku: 'QA-BOOK', quantity: 1 },
    });
    expect(response.ok()).toBeTruthy();
    return await response.json() as { id: string };
  });
}

The helper produces a report step wherever it is called during a test. Keep such helpers test-only because test.step() requires execution in Playwright Test context.

5. Box Steps to Improve Error Call Sites

By default, an error points to the exact failing line inside the step. That is ideal while debugging test implementation. With { box: true }, Playwright reports the failure at the step call site, which is useful when a reusable helper's internals are not actionable to the test author.

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

async function signIn(page: Page, email: string, password: string) {
  await test.step('Sign in as a member', async () => {
    await page.goto('/login');
    await page.getByLabel('Email').fill(email);
    await page.getByLabel('Password').fill(password);
    await page.getByRole('button', { name: 'Sign in' }).click();
    await expect(page.getByRole('banner')).toContainText(email);
  }, { box: true });
}

test('member sees saved searches', async ({ page }) => {
  await signIn(page, 'member@example.test', 'local-test-password');
  await page.goto('/saved-searches');
  await expect(page.getByRole('heading', { name: 'Saved searches' }))
    .toBeVisible();
});

If login fails, the boxed report emphasizes the signIn() call in the test. The trace and nested action information still help diagnose the internal cause. Boxing is a presentation decision, not exception handling.

Use boxing for stable domain helpers and page object operations whose caller should own the failure. Leave new or low-level helpers unboxed while their internal lines are the most useful location. Applying box: true to every step can hide the specific code path an SDET needs.

6. Set a Timeout for One Step

The timeout option bounds the complete step callback. The default is zero, which means the step itself adds no separate timeout, although test, action, navigation, and assertion timeouts still apply.

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

test('publishes a draft', async ({ page }) => {
  await page.goto('/drafts/qa-guide');

  await test.step('Publish the draft', async () => {
    await page.getByRole('button', { name: 'Publish' }).click();
    await expect(page.getByRole('status')).toHaveText('Published');
  }, { timeout: 10_000 });
});

If all operations do not complete within 10 seconds, test.step() throws a timeout error and the test fails. The step timeout does not expand the overall test timeout. If only five seconds remain in the test budget, setting a 10-second step limit cannot create more time.

Choose a step timeout when a business operation has a clear bound distinct from individual actions. Do not set large arbitrary values on every step. Locator assertions can set focused timeouts, and navigation has its own controls. For a systematic timeout investigation, use the Playwright 30000ms timeout troubleshooting guide.

Avoid Promise.race() wrappers around Playwright actions for timeout control. They can leave the losing operation active and produce confusing later behavior. Use Playwright's documented step, action, navigation, assertion, and test timeout layers.

7. Attach Evidence to a Specific Step

The callback can receive TestStepInfo. Its attach() method associates a body or file with the current step, while testInfo.attach() attaches at test level. Await the attachment call so Playwright can copy file-based artifacts before your code removes temporary files.

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

test('renders account details', async ({ page }) => {
  await page.goto('/account');

  await test.step('Verify account header', async step => {
    const screenshot = await page.screenshot({ fullPage: false });
    await step.attach('account-header', {
      body: screenshot,
      contentType: 'image/png',
    });

    await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();
  });
});

For JSON evidence, serialize only the fields needed for triage:

await test.step('Record order summary', async step => {
  const summary = { orderId: 'qa-102', state: 'submitted' };
  await step.attach('order-summary', {
    body: JSON.stringify(summary, null, 2),
    contentType: 'application/json',
  });
});

Attachments are often retained outside the local machine. Redact authorization headers, tokens, personal data, and payment details. Prefer a correlation ID and a small sanitized payload over an entire response or page dump.

Configure automatic screenshots and traces globally when they serve all failures. Step attachments should provide domain evidence tied to a particular operation.

8. Skip a Step Conditionally With TestStepInfo

Current Playwright Test passes a TestStepInfo object to the callback. Call step.skip(condition, description) to stop the current step and mark it skipped while allowing the rest of the test to continue.

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

test('shows desktop and common navigation', async ({ page, isMobile }) => {
  await page.goto('/dashboard');

  await test.step('Verify desktop sidebar', async step => {
    step.skip(isMobile, 'desktop sidebar is replaced by the mobile menu');
    await expect(page.getByRole('navigation', { name: 'Sidebar' })).toBeVisible();
  });

  await test.step('Verify account menu', async () => {
    await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible();
  });
});

The body after step.skip(true, reason) does not execute, but later steps can run. This differs from test.skip(), which skips the test. Use it only when the test still has independent value without that step. If the skipped operation is a prerequisite for later assertions, skip or split the test instead.

Playwright also exposes test.step.skip(), but current documentation recommends the callback's step.skip() for conditional control. A skipped step should have a precise product or configuration reason. It is not a substitute for catching an assertion failure.

Regularly review skipped steps. A report full of permanently skipped verification can create a false sense of coverage.

9. Add Steps to Page Objects and Helpers

Page object methods are natural step boundaries when they represent domain operations. You can call test.step() inside each high-level method and use box: true so failures point to the method call.

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

export class CheckoutPage {
  constructor(private readonly page: Page) {}

  async submitShippingAddress(address: {
    street: string;
    city: string;
    postalCode: string;
  }) {
    await test.step('Submit shipping address', async () => {
      await this.page.getByLabel('Street').fill(address.street);
      await this.page.getByLabel('City').fill(address.city);
      await this.page.getByLabel('Postal code').fill(address.postalCode);
      await this.page.getByRole('button', { name: 'Continue to payment' }).click();
      await expect(this.page.getByRole('heading', { name: 'Payment' }))
        .toBeVisible();
    }, { box: true });
  }
}

Do not make low-level getters such as emailInput() into steps. Locators and Playwright actions already appear in detailed reports. Step only operations that a tester or product engineer would recognize.

The official API also supports a TypeScript method decorator pattern that wraps decorated methods in test.step(). Decorators reduce repetition but add language configuration and indirection. A direct wrapper is easier for many teams to review and debug. Adopt a decorator only if the repository already has a clear decorator standard and compiler support.

Keep page objects free from test ordering assumptions. A step title can describe the operation, but it should not hide that the method depends on a prior test.

10. Read Steps in Reports and Trace Viewer

Playwright records user steps alongside categories for actions, assertions, hooks, and fixtures. HTML reports make failed steps easy to expand. Trace Viewer adds a timeline, DOM snapshots, network details, console messages, source, and attachments when tracing is configured.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  reporter: [['html', { open: 'never' }]],
  use: {
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
});

Step titles become a diagnostic interface. Keep wording stable enough that humans and custom reporters can compare runs. Do not place timestamps, random IDs, or entire input objects in every title. Put variable details in a focused attachment when needed.

Review the collapsed view as well as the expanded failure. A release manager should understand the scenario from top-level titles, while an SDET should be able to expand the failed phase and reach the locator or assertion. If only the author can interpret abbreviations in the titles, rename them with product vocabulary. Reports are shared engineering artifacts, not private debug logs.

When multiple tests call the same helper, consistent step names also reveal common failure boundaries. A wave of failures in "Authenticate test member" points toward identity setup, while scattered failures in unrelated verification steps point elsewhere. Use that signal as a triage starting point, then confirm it with traces and service evidence rather than assuming the title proves the root cause.

TestStepInfo.titlePath exposes the current path including parent step titles. Reporter APIs expose completed TestStep structures with title, category, duration, error, children, and attachments. Custom reporting is useful for measuring slow business phases, but avoid turning step duration into a fabricated performance benchmark. Browser test timing is affected by environment load and is not a substitute for performance testing.

Steps work especially well with retry traces. The Playwright test retry annotation examples guide explains how to capture retry evidence without hiding flaky outcomes.

11. A Practical Playwright test.step Design Checklist

Before merging step-heavy code, check the following:

  1. Each title describes intent with a verb and a domain object.
  2. Every test.step() promise is awaited or returned.
  3. Assertions live in the step that establishes their expected condition.
  4. Nesting stays shallow and improves report navigation.
  5. Boxing is chosen based on the most actionable error location.
  6. A step timeout reflects a real operation bound and fits inside the test timeout.
  7. Attachments are small, relevant, redacted, and awaited.
  8. Conditional step skipping preserves the remaining test's independent value.
  9. Page object steps represent business operations, not locator getters.
  10. Titles avoid secrets and high-cardinality data.
  11. Steps do not replace fixture lifecycle, assertions, or cleanup.

Code review should inspect an actual report for a representative failure. Step structure that looks tidy in source can be noisy after Playwright adds every nested action. Optimize for the engineer who has only the report and trace at 2 a.m., not only for the author reading the test.

Also inspect a passing report. Excessive step creation is easier to notice when everything succeeds and the reader must scan the complete scenario. Remove titles that merely restate one visible action, combine tiny operations that express one goal, and split a step when it hides two independently diagnosable outcomes. The objective is useful information density, not the highest step count.

For a larger cookbook of patterns, continue with Playwright test.step examples and best practices.

Interview Questions and Answers

Q: What is test.step() in Playwright?

It is an asynchronous API that groups test work under a named report step. Actions, assertions, nested steps, and attachments inside the callback are associated with that step. It improves diagnostics but does not add waiting or isolation by itself.

Q: Why should test.step() be awaited?

The callback is asynchronous and the method returns a promise. Awaiting it keeps execution sequential and propagates callback failures to the test. Omitting await can let teardown or later actions run too early.

Q: What does { box: true } change?

It changes the reported error location to the step call site when an error occurs inside. This is useful for stable reusable helpers whose caller is the actionable location. It does not catch or suppress the error.

Q: Can a Playwright step return a value?

Yes. The method resolves with the value returned by its callback. This is useful for API creation steps that return an ID or a model used by later steps.

Q: How is a step timeout different from a test timeout?

The step timeout bounds one callback and defaults to no separate limit. The test timeout bounds the broader test execution. A step timeout cannot extend the remaining test budget, and individual assertions or actions may have their own limits.

Q: What is the difference between step.attach() and testInfo.attach()?

step.attach() associates evidence with the current step. testInfo.attach() associates it with the test result. Both support a body or a file path and should be awaited.

Q: When should steps be nested?

Nest them when an outer business phase contains a few meaningful suboperations. Keep the tree shallow and inspect report output. Mechanical nesting for every click makes triage slower.

Common Mistakes

  • Calling test.step() without await or return.
  • Using step titles such as "click button" that repeat low-level actions instead of business intent.
  • Assuming a step retries its callback or adds automatic readiness checks.
  • Boxing every step and hiding the internal line that would be most useful for debugging.
  • Setting a step timeout larger than the remaining test timeout and expecting more time.
  • Catching an error only to mark the step as passed and let the test continue incorrectly.
  • Attaching tokens, customer details, or oversized payloads to retained reports.
  • Skipping a prerequisite step while allowing dependent assertions to run.
  • Adding steps around every page object getter and producing a deeply nested report.
  • Using steps to share state across tests instead of fixtures and independent setup.

Conclusion

Playwright test.step is a reporting boundary for coherent business actions. Await each call, use clear verb-led titles, keep assertions with the operation they verify, and add advanced options only when they improve failure diagnosis. Return values keep workflows typed, boxing controls error location, timeouts bound phases, and TestStepInfo supports focused attachments and conditional skips.

Begin with three to five top-level steps in one important end-to-end test, force a safe failure, and inspect the HTML report and trace. The right structure will make the failed business phase obvious without hiding the Playwright action that caused it.

Interview Questions and Answers

What problem does test.step solve?

It adds business-level structure to execution reports and traces. A good step groups related actions and assertions under a stable intent-focused title. It improves triage without changing normal Playwright synchronization or isolation.

Why must test.step be awaited?

The callback is asynchronous and test.step returns a promise. Awaiting it preserves sequence and lets errors propagate into the test result. Without it, later work or teardown may start before the step finishes.

How do boxed and unboxed steps differ?

An unboxed step points an error to the exact failing internal line. A boxed step points it to the step call site. I box stable domain helpers when the caller owns the failure and leave low-level or evolving code unboxed.

Can steps be nested and return values?

Yes. Nested steps form a report hierarchy, and the outer duration includes its children. A step also resolves with its callback return value, which supports typed data flow between phases.

When would you set a step timeout?

I use it for a business phase with a clear bounded duration distinct from individual actions. The value must fit within the test timeout. I do not apply large defaults to hide an unexplained slow suite.

What is TestStepInfo used for?

The callback receives it for step-specific control and context. Current APIs let the step attach evidence, skip conditionally, and inspect its title path. Attachments should be awaited, focused, and sanitized.

Should every page object method be a step?

No. High-level domain operations are useful step boundaries, while locator getters and trivial wrappers create noise. I inspect a real report before standardizing page object steps across a suite.

Frequently Asked Questions

What does test.step do in Playwright?

It records an asynchronous callback as a named logical step in reports, UI mode, and traces. It groups nested actions and assertions but does not add waiting, retrying, or browser isolation by itself.

Do I need to await Playwright test.step?

Yes. Await or return the promise so later actions do not overlap the step and callback failures reach the test. Asynchronous operations inside the callback must also be awaited.

Can test.step return a value?

Yes. The method resolves with whatever the callback returns. It is useful for returning an API-created ID or typed model to a later phase.

What does box true mean on a Playwright step?

It reports errors at the step call site rather than the exact failing line inside the callback. Use it for stable helpers when the caller is the better ownership boundary.

Does a step timeout override the test timeout?

No. The step timeout limits that callback but does not extend the overall remaining test budget. Action and assertion timeouts can still expire first.

How do I attach a screenshot to one Playwright step?

Receive TestStepInfo as the callback argument, take a screenshot buffer, and await step.attach() with the body and image/png content type. Use testInfo.attach() instead when evidence belongs to the whole test.

Can I skip only one Playwright step?

Yes. Call step.skip(condition, description) inside the callback. The current step stops and is marked skipped, while later test code can continue if it is independent.

Related Guides