Testing Multi-Step Form Wizards

A wizard splits one long form into steps, and in doing so creates a set of behaviours a single form never had. Each step must validate before the user can move on, but only its own fields. Going back must preserve what was entered. Moving to a new step must put focus somewhere sensible and tell screen-reader users where they are. Conditional steps appear or disappear based on earlier answers. And at the end, everything collected across all steps must be submitted once, correctly assembled. This guide covers testing each of those behaviours at the component tier with user-event, building a small navigation helper so tests read as journeys, and keeping the number of tests proportional to the number of genuinely distinct paths. It sits under form and validation testing.

Root Cause Analysis

Wizard bugs cluster at step boundaries. Validation that checks the whole form on every step blocks the user on step one because step three’s fields are empty. Validation that checks nothing lets the user reach the end with incomplete data and fail at submission, far from the field that caused it. State held in the step component rather than above it is lost when the user goes back, because the step unmounts. None of these shows up in a test that only walks forward with valid data.

Focus and announcements are the second cluster, and they matter more in a wizard than in a single form. When the step changes, the old content disappears and new content appears — for a sighted mouse user this is obvious, while a keyboard user’s focus is left on a button that no longer exists, and a screen-reader user hears nothing at all unless the change is announced.

Conditional steps are the third. A wizard that asks “Are you buying for a business?” and adds a company-details step only when the answer is yes has two paths, and each needs coverage. Teams typically test the path they built most recently, which leaves the other to regress.

A wizard with a conditional step has two paths The personal path goes from account to delivery to review, while the business path inserts a company-details step between account and delivery; each path needs its own journey test, and going back must preserve every step's data on both. 1. account business? y/n 2b. company only if business 3. delivery 4. review submit once personal path two journeys, and back navigation on each must keep every step's input
Every conditional step doubles the paths; each path is one journey test, not one test per step.

Reproducible Setup

A wizard whose state lives above the steps, with a schema per step so each step validates only its own fields.

// src/onboarding/OnboardingWizard.tsx
const steps = [
  { id: 'account', title: 'Your account', schema: accountSchema, Component: AccountStep },
  { id: 'company', title: 'Company details', schema: companySchema, Component: CompanyStep, when: (d: Draft) => d.isBusiness },
  { id: 'delivery', title: 'Delivery address', schema: deliverySchema, Component: DeliveryStep },
  { id: 'review', title: 'Review and confirm', schema: null, Component: ReviewStep },
];

export function OnboardingWizard({ onComplete }: { onComplete: (d: Draft) => Promise<void> }) {
  const [draft, setDraft] = useState<Draft>(emptyDraft);
  const visible = steps.filter((s) => !s.when || s.when(draft));
  const [index, setIndex] = useState(0);
  const step = visible[index];
  const headingRef = useRef<HTMLHeadingElement>(null);
  useEffect(() => headingRef.current?.focus(), [index]);

  return (
    <section aria-labelledby="wizard-heading">
      <p aria-live="polite">Step {index + 1} of {visible.length}</p>
      <h2 id="wizard-heading" tabIndex={-1} ref={headingRef}>{step.title}</h2>
      <step.Component
        value={draft}
        onNext={(patch) => { setDraft({ ...draft, ...patch }); if (index < visible.length - 1) setIndex(index + 1); }}
        onBack={index > 0 ? () => setIndex(index - 1) : undefined}
        onSubmit={() => onComplete(draft)}
      />
    </section>
  );
}

Implementation

Step 1 — Write a small navigation helper. Tests then read as journeys — “complete the account step as a business” — rather than as sequences of field lookups.

// test/wizard.ts
import { screen } from '@testing-library/react';
import type { UserEvent } from '@testing-library/user-event';

export const wizard = (user: UserEvent) => ({
  heading: () => screen.getByRole('heading', { level: 2 }),
  next: () => user.click(screen.getByRole('button', { name: 'Continue' })),
  back: () => user.click(screen.getByRole('button', { name: 'Back' })),
  async account({ email = 'ada@example.test', business = false } = {}) {
    await user.type(screen.getByLabelText('Email'), email);
    if (business) await user.click(screen.getByLabelText('I am buying for a business'));
    await this.next();
  },
  async company(name = 'Analytical Engines Ltd') {
    await user.type(screen.getByLabelText('Company name'), name);
    await this.next();
  },
  async delivery(postcode = 'SW1A 2AA') {
    await user.type(screen.getByLabelText('Postcode'), postcode);
    await this.next();
  },
});

Step 2 — Test that each step validates only its own fields. An empty required field blocks progress with an error on that field; fields from later steps are not mentioned.

test('the account step blocks progress until its own fields are valid', async () => {
  const user = userEvent.setup();
  render(<OnboardingWizard onComplete={vi.fn()} />);
  await wizard(user).next();

  expect(screen.getByRole('heading', { name: 'Your account' })).toBeInTheDocument();
  expect(screen.getByLabelText('Email')).toHaveAccessibleDescription('Enter your email address');
  expect(screen.queryByText(/postcode/i)).not.toBeInTheDocument();
});

Step 3 — Test both paths through a conditional step. One journey test per path, asserting on the sequence of headings the user sees.

test.each([
  [false, ['Your account', 'Delivery address', 'Review and confirm']],
  [true, ['Your account', 'Company details', 'Delivery address', 'Review and confirm']],
])('business=%s visits %j', async (business, expected) => {
  const user = userEvent.setup();
  render(<OnboardingWizard onComplete={vi.fn()} />);
  const w = wizard(user);
  const seen = [w.heading().textContent];

  await w.account({ business });
  seen.push(w.heading().textContent);
  if (business) { await w.company(); seen.push(w.heading().textContent); }
  await w.delivery();
  seen.push(w.heading().textContent);

  expect(seen).toEqual(expected);
});

Step 4 — Test that going back preserves input. This catches state held in step components that unmount.

test('returning to an earlier step shows what was entered', async () => {
  const user = userEvent.setup();
  render(<OnboardingWizard onComplete={vi.fn()} />);
  const w = wizard(user);
  await w.account({ email: 'grace@example.test' });
  await w.back();
  expect(screen.getByLabelText('Email')).toHaveValue('grace@example.test');
});
Where wizard state must live State held inside a step component is lost when the user navigates away and the step unmounts, while state held in the wizard above the steps survives every back and forward movement and is assembled for the final submission. state in each step step unmounts on Continue Back remounts it empty the user retypes everything state in the wizard steps receive it as value Back shows what was typed one draft, submitted once
The back-navigation test is what catches state stored in the wrong place — no forward-only test can.

Step 5 — Test focus and progress on each step change. Focus should land on the new step’s heading, and the live region should announce the position.

test('moves focus to the new step and announces progress', async () => {
  const user = userEvent.setup();
  render(<OnboardingWizard onComplete={vi.fn()} />);
  await wizard(user).account();
  expect(screen.getByRole('heading', { name: 'Delivery address' })).toHaveFocus();
  expect(screen.getByText('Step 2 of 3')).toBeInTheDocument();
});

Step 6 — Test the final submission once, with the assembled payload. Every step’s data should arrive together, submitted exactly once.

test('submits the complete draft from every step', async () => {
  const user = userEvent.setup();
  const onComplete = vi.fn().mockResolvedValue(undefined);
  render(<OnboardingWizard onComplete={onComplete} />);
  const w = wizard(user);
  await w.account({ business: true });
  await w.company('Difference Engines plc');
  await w.delivery('EC1A 1BB');
  await user.click(screen.getByRole('button', { name: 'Confirm and create account' }));

  expect(onComplete).toHaveBeenCalledOnce();
  expect(onComplete.mock.calls[0][0]).toMatchObject({ isBusiness: true, companyName: 'Difference Engines plc', postcode: 'EC1A 1BB' });
});

The helper earns its place quickly. Wizard journeys repeat the same early steps in nearly every test, and without it each test re-types the account step’s fields by hand. With it, a change to that step’s labels is a one-line fix in the helper rather than an edit to every journey, and the tests read as the sequence of decisions a user makes rather than as keystrokes.

Verification

Confirm the back-navigation test catches misplaced state by moving the email into the step’s own useState. The preservation test must fail — which is exactly the bug users hit when they go back to correct something.

npx vitest run src/onboarding --reporter=verbose
# ✓ the account step blocks progress until its own fields are valid
# ✓ business=false visits ["Your account","Delivery address","Review and confirm"]
# ✓ business=true visits ["Your account","Company details","Delivery address","Review and confirm"]
# ✓ returning to an earlier step shows what was entered
# ✓ moves focus to the new step and announces progress

Then confirm the path tests are sensitive to the condition by inverting it — show the company step for personal accounts. Both parameterised cases should fail with the wrong heading sequence.

How many wizard tests are enough One gate test per step, one journey test per path, one back-navigation test, one focus test and one submission test cover a wizard; field rules belong to each step's schema tests rather than to the wizard suite. gate per step own fields only journey per path heading sequence back, focus one each submission assembled, once
Wizard tests scale with steps and paths, not with fields — the fields belong to their schemas.

Troubleshooting

Symptom: a step cannot be passed although its fields are valid. Diagnosis: the step validates against the whole wizard schema, so later steps’ empty fields fail. Fix: give each step its own schema, as in the setup, and combine them only for the final submission.

Symptom: focus assertions fail intermittently. Diagnosis: focus is moved in an effect that runs after the assertion. Fix: await the step change with a findBy query for the new heading, then assert focus.

Symptom: going back and forward duplicates entries in a list step. Diagnosis: the step appends to the draft on each Continue rather than replacing its own section. Fix: have each step return a patch for its own keys, merged by the wizard, and add a test that goes back and forward twice.

Symptom: the conditional step’s data remains after the condition is turned off. Diagnosis: the draft keeps company details after the user unticks “business”. Fix: decide whether to clear or ignore hidden steps’ data, and assert the submitted payload reflects that decision.

FAQ

Should each step be tested in isolation too?

Yes, for its own validation and rendering — a step is a component with a value and callbacks, easy to render alone. The wizard tests then focus on what only the wizard does: sequencing, conditional steps, preserved state and the combined submission.

How do I test a wizard that saves progress to the server?

Intercept the save endpoint with MSW and assert it receives each step’s patch as the user continues. Add a test that reloads — renders the wizard fresh with the saved draft — and lands on the right step with the right data, since resuming is the feature users rely on.

Does this belong in end-to-end tests instead?

One end-to-end journey confirms the wizard is wired into the real page and the real API. Everything above — gates, paths, back navigation, focus — is faster, more precise and more thorough at the component tier, as mapping user journeys to test layers explains.

What about browser back-button navigation?

If steps are routes, the browser’s back button is a navigation, and the router test pattern applies — render with a memory router, navigate back, assert the previous step and its data. If steps are local state, decide whether the back button should leave the wizard, and test that the user is warned before losing their progress.