Playwright architecture, selector reliability, and advanced interaction patterns.

Automating Multi-Step Forms with Playwright

A multi-step form wizard packs every hard synchronization problem into one workflow. Each step unmounts and remounts a container, navigation buttons fire validation requests whose timing varies run to run, and the application often advances the UI optimistically before the backend has confirmed the data. A test written as a flat sequence of fills and clicks passes on a fast local machine and fails intermittently in CI, because it interacts with a panel that has not attached yet or clicks "Next" before the server has accepted the previous step. The fix is to treat each transition as an explicit checkpoint: wait for the next step to attach, gate the advance on the validation response, and assert on the URL or panel state rather than an arbitrary delay. This guide extends the field-level techniques in Form Automation & Input Handling and the assertion patterns across Advanced Interactions & Test Assertions.

Multi-step form state machine with validation gates Each step fills fields, waits for the validation response, then waits for the next panel to attach before continuing to submission. Step 1 Account Step 2 Contact Step 3 Confirm Success toHaveURL waitForResponse() waitFor(attached) Each arrow is a validated, attach-checked transition
Treat the wizard as a state machine: every transition gates on the validation response and the next panel attaching before the test fills the following step.

Root cause: optimistic transitions and remounted panels

Two failure modes account for nearly every flaky multi-step form test, and each has a precise, non-timing-based remedy.

The first is panel remounting. Wizards built on component frameworks rarely keep all steps in the DOM; they unmount the current panel and mount the next one during the transition. A locator that resolved to a step-two field a moment ago points at a node the framework has since replaced, so the fill throws a detachment error or silently targets a stale element. Auto-waiting handles standard actionability, but it cannot guess that you intend to wait for a panel that has not been created yet. The remedy is to wait for the next panel to reach the attached (or visible) state before touching any field inside it, anchoring locators to a stable container or a role rather than a generated class — the same resilience argument made in Why getByRole Beats CSS Selectors in Modern Apps.

The second is optimistic navigation. Many wizards enable the "Next" button immediately and only block the real transition once a validation request returns. If the test clicks "Next" and proceeds without waiting for that request, it advances against unvalidated state — the backend may still reject the data, leaving the UI in an inconsistent place or redirecting unexpectedly. The fix is to register page.waitForResponse() before the click and await it after, so the test only advances once the server has confirmed the step. Both problems read as flakiness, yet both are deterministic once you gate transitions on real signals instead of visual guesses.

A third, quieter failure mode compounds the other two: wizards that persist progress. Pressing the browser back button, resuming a saved draft, or reloading mid-flow restores earlier answers from localStorage or a server-side draft record, so a test that assumes empty inputs types on top of existing values and submits a corrupted payload. Treat the starting state as something you control rather than inherit — clear storage in a fixture or seed it deliberately — and assert which step is active before typing into it. The ordering below shows exactly where each signal lands relative to the click.

Ordering of the validation waiter around a step transition A sequence diagram in which the test registers the response waiter before clicking, the UI posts to the validation endpoint, and the next panel attaches only after the response returns. Test script Wizard UI Validation API fill step 1 fields register waitForResponse() click Continue POST /api/validate 200 accepted awaited promise resolves waitFor attached on step 2 The waiter is created before the click, so the response can never be missed
Registering the response waiter one line ahead of the click removes the race: the promise already exists when the API answers, however fast the round trip is.

Minimal reproducible example

The helper below drives a three-step registration wizard. Each transition waits for the validation response and the next panel to attach before interacting, so a slow render or a delayed API can never desynchronize the test.

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

// A single typed payload keeps the wizard data reviewable in one place.
type RegistrationData = { username: string; email: string };

async function completeMultiStepForm(page: Page, data: RegistrationData) {
  await page.goto('/registration');

  // Step 1: fill account details on the first panel.
  await page.getByRole('textbox', { name: 'Username' }).fill(data.username);

  // Register the validation waiter BEFORE clicking so the response is never missed.
  const step1Validated = page.waitForResponse(
    (resp) => resp.url().includes('/api/validate') && resp.status() === 200,
  );
  await page.getByRole('button', { name: 'Continue' }).click();
  await step1Validated; // advance only after the backend confirms step 1.

  // Step 2: wait for the panel to attach before touching its fields.
  await page.locator('#step-2-panel').waitFor({ state: 'attached' });
  await page.getByRole('textbox', { name: 'Email' }).fill(data.email);
  await page.getByRole('button', { name: 'Next' }).click();

  // Step 3: wait for the final panel, accept terms, and submit.
  await page.locator('#step-3-panel').waitFor({ state: 'visible' });
  await page.getByRole('checkbox', { name: 'Terms' }).check();
  await page.getByRole('button', { name: 'Submit' }).click();

  // Assert on the destination URL — the real end-state the user reaches.
  await expect(page).toHaveURL(/\/success$/);
}

test('completes the registration wizard', async ({ page }) => {
  await completeMultiStepForm(page, { username: 'ada', email: 'ada@example.com' });
});

Two properties make that helper worth reusing. It receives the page and a typed payload instead of reading module-level state, so the same function can drive a happy path, an error path, and a data-driven matrix of payloads. And every await inside it corresponds to a signal the application genuinely emits, so there are no tuning knobs to re-tune when the CI machine gets slower. Once a second spec needs the wizard, promote the helper to a fixture using the composition approach in Setting Up Global Fixtures for Parallel Tests, so the wizard's contract lives in one place. The timeline below contrasts what the gated version does with the flat script it replaces.

Flat script versus gated script on the same transition A two-lane timeline comparing a flat fill-and-click script that types into a remounting panel with a gated script that waits for validation first. Flat script fill step 1 click Next fill step 2 the fill lands while the panel is still remounting Gated script fill step 1 click Next await validation fill step 2 the server confirms before the next panel is filled 0 ms 300 600 900 1200 The gated run is later, and it is the one that passes
The gated run finishes the transition a few hundred milliseconds later than the flat run — and it is the only one whose second fill reaches a live panel.

Step-by-step fix

  1. Wait for each step's panel to attach. After every transition, call await page.locator('#step-N-panel').waitFor({ state: 'attached' }) before interacting with its fields. This eliminates detachment errors caused by the framework remounting the panel during the transition.
  2. Anchor locators to stable handles. Prefer getByRole() and getByLabel(), or a data-testid on the panel, over generated class names. Role- and label-based locators survive markup churn between steps and across redesigns.
  3. Register the validation waiter before the click. Create const validated = page.waitForResponse(...) immediately before clicking "Next", then await validated after. Registering after the click loses the race when the response returns first.
  4. Gate the advance on the response, not a delay. Await the validation response and assert its status before proceeding. Never substitute page.waitForTimeout() — a fixed sleep is both slower than needed and still flaky under load. The trade-offs between page-level and element-level waiting are compared in Waiting for Network Idle vs Element State.
  5. Confirm arrival at the next step. After advancing, assert the new state with page.waitForURL() or by checking the active step indicator, so a failed transition surfaces immediately instead of one step later.
  6. Isolate session and state per worker. For authenticated wizards, load a saved session with test.use({ storageState: 'state.json' }) so logins do not repeat and cookies do not bleed across parallel workers. Combine this with Browser Contexts & Isolation for clean separation, and see Reusing Login State with storageState for the setup-project shape.
  7. Make conditional branches explicit. Wizards frequently insert, skip, or reorder steps based on earlier answers — a business account adds a tax step, a domestic address removes a customs step. Assert which panel actually rendered after each advance, for example await expect(page.getByRole('heading', { name: 'Contact' })).toBeVisible(), instead of assuming a fixed step count. A branch that changes then fails on the transition that caused it rather than three steps downstream, which is the difference between a five-minute fix and an afternoon of trace reading.

Troubleshooting variants

Most wizard failures reduce to one of four symptoms, and each has a timing hack people reach for first and a signal that actually resolves it.

Wizard symptoms mapped to timing hacks and real signals A three-column matrix listing four wizard failure symptoms, the timing hack commonly applied to each, and the deterministic signal that replaces it. Match the symptom to a signal, not to a sleep Symptom Timing hack that fails Signal to gate on Detached element error waitForTimeout(500) waitFor({ attached }) Advances unvalidated sleep after the click await waitForResponse() Assertion hits old step fixed wait then assert waitForURL(/step-2/) Login repeats per test re-type credentials storageState fixture
Every row replaces a duration you invented with an event the application already emits, which is why the right-hand column holds under CI load.

Filling a field throws "element is not attached to the DOM"

The previous transition remounted the panel after your locator resolved. Re-resolve the field locator immediately before the fill and wait for the panel container with waitFor({ state: 'attached' }) first. If the wizard recreates nodes on every keystroke or validation pass, wrap the interaction in expect(async () => { /* fill + assert */ }).toPass() so Playwright re-resolves and retries until the panel settles. Anchor to a stable #step-N-panel rather than an inner element that the framework regenerates. React wizards that re-render on each controlled-input change are a common source of this pattern; the render-cycle detail is unpacked in Waiting Strategies for Dynamic React Components.

The test advances before the backend validates the step

Optimistic UI enabled the button before the request returned, and the click outran the validation. Register page.waitForResponse() before the click and await it after, asserting the expected status. If a transient 5xx can occur under load, wrap the advance in expect().toPass() so the suite retries the navigation rather than failing on a single blip:

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

async function advanceWithRetry(page: Page) {
  // Retry the whole advance so a transient 5xx or slow response self-heals.
  await expect(async () => {
    await page.getByRole('button', { name: 'Next Step' }).click();
    await page.waitForURL(/\/step-\d+$/, { timeout: 5000 });
    await expect(page.locator('.step-indicator.active')).toBeVisible();
  }).toPass({ timeout: 15000, intervals: [1000, 2000, 3000] });
}

Keep the retry scoped to the transition rather than the whole test. A retried wizard run re-submits step one, and an endpoint that is not idempotent will reject the duplicate or create a second record, turning a timing problem into a data problem.

You need to test the error path without a flaky backend

Drive the validation endpoint yourself so the failing branch is deterministic. Intercept the request and return a controlled error, then assert the wizard surfaces the right message and does not advance. This reuses the technique from Mocking API Responses with Playwright:

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

test('shows a validation error on a 422 response', async ({ page }) => {
  // Answer the validation call with a controlled failure, no real backend needed.
  await page.route('**/api/validate', (route) =>
    route.fulfill({
      status: 422,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Email already in use' }),
    }),
  );

  await page.goto('/registration');
  await page.getByRole('textbox', { name: 'Email' }).fill('taken@example.com');
  await page.getByRole('button', { name: 'Next' }).click();

  // The UI must surface the error and stay on the same step.
  await expect(page.getByRole('alert')).toContainText('Email already in use');
});

Assert the negative half of that contract as well: the step counter must not increment and the URL must not change. A wizard that shows the error banner and advances is a real defect, and a test that only checks the banner will never catch it.

Verification

Confirm the wizard test is stable on four fronts. First, run it with npx playwright test --repeat-each=10; gating on the response and the attach state should yield ten clean passes with no detachment or premature-advance failures. Second, assert the end-state explicitly with await expect(page).toHaveURL(/\/success$/) so a half-completed run cannot pass. Third, open the run in the Playwright Trace Viewer with --trace on and step through the timeline — each validation request should resolve before its transition, which proves the test advanced on real signals rather than timing luck.

Fourth, run the spec under the CI configuration rather than local defaults, with workers set to the number the pipeline actually uses. Contention is what exposes the remaining races: measure the slowest transition across those runs and derive the action timeout from that measurement instead of guessing, the reasoning developed in Configuring Retries and Timeouts for Stable CI. If the spec still needs a retry to go green, treat that as evidence of a missing gate rather than a reason to raise the retry count — a wizard test that passes only on attempt two is telling you which transition is unguarded. For the individual controls inside each step, see the sibling guide on Handling Dropdowns, Checkboxes, and Radio Buttons.

Frequently Asked Questions

Why does my test fail when clicking Next on a multi-step form?

The button is enabled optimistically and your click outruns the validation request, so the test advances against unconfirmed state. Register page.waitForResponse() before the click and await it after, asserting the expected status, so the test only proceeds once the backend has accepted the step.

How do I handle a step panel that disappears between transitions?

The framework remounts the panel during the transition, detaching your locator. Wait for the next panel with waitFor({ state: 'attached' }) before interacting, anchor locators to a stable container or role, and re-resolve fields immediately before each fill.

Should I use waitForTimeout to wait for the next step?

No. A fixed sleep is slower than necessary and still flaky under variable load. Gate transitions on real signals: await the validation response and assert the next panel attached or the URL changed with page.waitForURL().

How do I test a wizard whose steps depend on earlier answers?

Drive the branch from data, not from position. Parameterize the helper with the payload that selects the branch, then assert the heading or step indicator of the panel you expect after each advance rather than counting steps. That way a reordered branch fails on the transition that changed, and one spec per branch documents the paths the product actually supports.

Back to overview