Q27 of 48 · Cypress

What's the cleanest way to test a multi-step form?

CypressMidcypresswizardspage-objectsmid

Short answer

Short answer: Encapsulate each step in a small page object or custom command, drive the happy path through them, and add per-step tests for validation and back-navigation. Stub or seed inputs that aren't the focus of the test so you can land on a specific step quickly.

Detail

Multi-step forms accumulate noise fast — each test ends up duplicating the steps before its target. Three techniques keep them readable:

1. Encapsulate steps. A small page object or custom command per step with the actions you actually care about (fillProfile(values), fillPlan(plan), submit()). Specs read at the level of the workflow, not the inputs.

2. Seed-and-jump. Don't make every test traverse all steps. Use cy.task to insert a partial draft directly into the database, then cy.visit('/wizard/step3') to land where you need to test. Only step 3's behaviour is exercised; the prior steps are tested in their own specs.

3. Test the failure modes per step. State preservation across back-nav, refresh recovery, validation, and submit-twice are usually where bugs hide. Each gets its own test, scoped to one step.

A complete spec structure:

  • One happy-path spec that walks all steps end-to-end (canary).
  • Per-step specs for validation and field behaviour.
  • Cross-cutting specs for back-navigation, refresh recovery, two-tab editing.

Avoid the temptation to test all field validations through a happy-path traversal — they balloon the spec and make individual failures hard to interpret.

// EXAMPLE

wizard.cy.ts

// support/wizard.ts
export const Wizard = {
  fillProfile({ name, email }: { name: string; email: string }) {
    cy.get('[data-test=name]').type(name);
    cy.get('[data-test=email]').type(email);
    cy.get('[data-test=next]').click();
  },
  fillPlan(plan: 'free' | 'pro') {
    cy.get(`[data-test=plan-${plan}]`).click();
    cy.get('[data-test=next]').click();
  },
  submit() {
    cy.get('[data-test=confirm]').click();
  },
};

// signup.cy.ts — happy path
import { Wizard } from '../support/wizard';

describe('Signup wizard', () => {
  it('completes the happy path', () => {
    cy.visit('/signup');
    Wizard.fillProfile({ name: 'Alice', email: 'alice@x.com' });
    Wizard.fillPlan('pro');
    Wizard.submit();
    cy.url().should('include', '/welcome');
  });

  it('preserves data on back navigation (step 2 → step 1)', () => {
    cy.visit('/signup');
    Wizard.fillProfile({ name: 'Bob', email: 'bob@x.com' });
    cy.get('[data-test=back]').click();
    cy.get('[data-test=name]').should('have.value', 'Bob');
  });

  it('rejects invalid email at step 1', () => {
    cy.visit('/signup');
    cy.get('[data-test=email]').type('not-an-email');
    cy.get('[data-test=next]').click();
    cy.get('[data-test=email-error]').should('be.visible');
  });
});

// WHAT INTERVIEWERS LOOK FOR

Encapsulation per step, seed-and-jump for non-target steps, and one happy-path canary plus per-step failure specs.

// COMMON PITFALL

Writing one mega-test that walks all steps and asserts at each — it duplicates setup across specs and makes failures hard to localise.