Multi-Step Form and Wizard Testing

8 min read

Wizards — checkout, signup, onboarding, anything that splits a form into "next" and "previous" buttons — are some of the trickiest UI to test. Each step has its own validation; users can go back; refreshing mid-flow may or may not preserve state; skipping a step is sometimes a feature and sometimes a bug. This lesson covers the test architecture that handles all of that without producing a 200-line it block: per-step tests in isolation, one end-to-end happy-path test, and the API-or-app-actions setup that lets each step's spec start mid-wizard.

The two-axis testing strategy

Multi-step forms get covered along two dimensions:

  • Per-step (isolated) — each step is one or more it blocks. The spec sets up the prerequisites (login, prior-step state) via API or app actions, then exercises only the step under test. Validation rules, error messages, edge cases all live here.
  • End-to-end (happy path) — one it per wizard that drives every step in order. Verifies that the steps connect: data carries forward, the final submit succeeds, the confirmation shows what the user typed.

Most teams write five to ten per-step tests for every one end-to-end test. Per-step tests are fast (each one starts mid-wizard via API setup), end-to-end tests are slow but few.

Don't try to cover every validation through the end-to-end path — you'll have one 30-step test that times out on flake and tells you nothing useful when it fails.

Setup: jumping into the middle of a wizard

The hardest part of testing step 3 isn't step 3 — it's getting to step 3 without typing through steps 1 and 2 every time. Three approaches, in increasing speed:

// Slowest — drive every prior step through the UI
beforeEach(() => {
  cy.visit("/checkout/shipping");
  cy.get("[data-testid='address']").type("123 Test Street");
  cy.get("[data-testid='city']").type("London");
  cy.get("[data-testid='postcode']").type("SW1A 1AA");
  cy.get("[data-testid='next-step']").click();
});
 
// Faster — seed prior-step state via API
beforeEach(() => {
  cy.request("POST", "/api/checkout/sessions", {
    shippingAddress: {
      line1: "123 Test Street",
      city: "London",
      postcode: "SW1A 1AA",
    },
  });
  cy.visit("/checkout/payment");
});
 
// Fastest — app actions if your frontend exposes them
beforeEach(() => {
  cy.window().then((win) => {
    win.appActions.setCheckoutState({
      step: "payment",
      shippingAddress: { line1: "123 Test Street", city: "London", postcode: "SW1A 1AA" },
    });
  });
  cy.visit("/checkout/payment");
});

Mix and match: API for state the backend owns (cart contents, order draft); app actions for client-side state (form drafts in a Zustand store); UI only when the test specifically exists to verify the navigation.

Per-step tests for a checkout wizard

A typed spec for the second step (payment) of a four-step checkout — the test never types into shipping, never clicks "Next" out of step 1; it lands directly on /checkout/payment with the shipping data already seeded:

describe("Checkout wizard — payment step", () => {
  beforeEach(() => {
    cy.sessionLogin("alice@test.com", "Sup3rS3cret!");
    cy.request("POST", "/api/test/checkout-sessions", {
      cart: [{ productId: 1, quantity: 1 }],
      shippingAddress: {
        line1: "123 Test Street",
        city: "London",
        postcode: "SW1A 1AA",
      },
    });
    cy.visit("/checkout/payment");
  });
 
  it("requires a card number", () => {
    cy.get("[data-testid='next-step']").click();
    cy.get("[data-testid='card-number-error']")
      .should("contain", "Card number is required");
  });
 
  it("rejects an invalid card number", () => {
    cy.get("[data-testid='card-number']").type("1234");
    cy.get("[data-testid='next-step']").click();
    cy.get("[data-testid='card-number-error']")
      .should("contain", "Invalid card number");
  });
 
  it("accepts a valid card and advances to the review step", () => {
    cy.get("[data-testid='card-number']").type("4242424242424242");
    cy.get("[data-testid='card-expiry']").type("12/29");
    cy.get("[data-testid='card-cvc']").type("123");
    cy.get("[data-testid='next-step']").click();
    cy.url().should("include", "/checkout/review");
  });
 
  it("returns to shipping when the back button is clicked", () => {
    cy.get("[data-testid='back-step']").click();
    cy.url().should("include", "/checkout/shipping");
    cy.get("[data-testid='address']").should("have.value", "123 Test Street");
  });
});

Four tests, one shared setup, each test isolating exactly one branch of the payment step's behaviour. The validation rules and the back-button semantics get full coverage without any interaction with steps 3 or 4.

Step-skipping and direct-URL access

Most wizards guard against users jumping to step 3 with no shipping data — visiting /checkout/payment without prior state should redirect to /checkout/shipping. Tests for that guard belong here too:

it("redirects to shipping if payment is loaded without prior state", () => {
  // No setup — no checkout session, no shipping data
  cy.visit("/checkout/payment");
  cy.url().should("include", "/checkout/shipping");
});
 
it("preserves data when navigating back to shipping", () => {
  cy.visit("/checkout/payment");
  cy.get("[data-testid='back-step']").click();
  cy.get("[data-testid='address']").should("have.value", "123 Test Street");
});

Step navigation rules — you can't skip forward, you can go back, going back doesn't lose data — are the bugs every wizard has at some point. Cover them once explicitly and move on.

Refresh-survival testing

Wizards that lose user input on F5 are rage-inducing. Test the survival explicitly:

it("preserves form data after a page refresh", () => {
  cy.visit("/checkout/payment");
  cy.get("[data-testid='card-number']").type("4242424242424242");
  cy.get("[data-testid='card-expiry']").type("12/29");
 
  cy.reload();
 
  cy.get("[data-testid='card-number']").should("have.value", "4242424242424242");
  cy.get("[data-testid='card-expiry']").should("have.value", "12/29");
});

If the wizard uses sessionStorage, localStorage, or a server-side session for draft persistence, this is the test that proves it. If it doesn't (and the requirement says it should), this is the test that fails the right way.

The end-to-end happy-path test

One it block per wizard that drives every step in order:

it("completes the full checkout from cart to confirmation", () => {
  cy.sessionLogin("alice@test.com", "Sup3rS3cret!");
  cy.request("POST", "/api/test/cart/items", { productId: 1, quantity: 2 });
 
  cy.visit("/checkout/shipping");
  cy.get("[data-testid='address']").type("123 Test Street");
  cy.get("[data-testid='city']").type("London");
  cy.get("[data-testid='postcode']").type("SW1A 1AA");
  cy.get("[data-testid='next-step']").click();
 
  cy.url().should("include", "/checkout/payment");
  cy.get("[data-testid='card-number']").type("4242424242424242");
  cy.get("[data-testid='card-expiry']").type("12/29");
  cy.get("[data-testid='card-cvc']").type("123");
  cy.get("[data-testid='next-step']").click();
 
  cy.url().should("include", "/checkout/review");
  cy.get("[data-testid='review-address']").should("contain", "123 Test Street");
  cy.get("[data-testid='review-card']").should("contain", "•••• 4242");
  cy.get("[data-testid='place-order-btn']").click();
 
  cy.url().should("include", "/checkout/confirmation");
  cy.get("[data-testid='order-number']").should("be.visible");
  cy.contains("Thank you for your order").should("be.visible");
});

This test alone is worth keeping at full UI fidelity — it's the smoke test that proves the four steps connect end to end. One per wizard.

The wizard-test architecture visualised

Step 1 of 5

Step 1 — Shipping

5 isolated tests: each starts at /checkout/shipping with cart pre-seeded via API. Tests cover required fields, postcode format, country dropdown.

⚠️ Common mistakes

  • Driving every step through the UI in every test, "for realism." A 30-test suite that types through four steps each takes two minutes per test in setup alone — an hour of CI just for the setup, with the test bodies adding maybe ten minutes. Use API or app-actions setup to land on the step under test; reserve UI navigation for the one end-to-end happy-path test.
  • Putting every wizard validation rule into the end-to-end test. The end-to-end test fails on the first error and stops; you can't see whether the other twelve validation rules are still working. Keep the end-to-end as a happy path and put validation logic in per-step it blocks.
  • Forgetting the back-and-forward semantics. Wizards that don't preserve data on back navigation are universally hated. Refresh-survival, back-button, and the "you can't skip forward" guard are bugs that ship to production unless you write a test for them. They take five minutes each and prevent the most embarrassing UX bugs.

🎯 Practice task

Build a per-step + end-to-end test architecture for a real wizard. 30-40 minutes.

  1. Pick a wizard target. Sauce Demo's checkout (information → overview → complete) works as a three-step wizard. Or use any signup/onboarding flow you control.
  2. Create cypress/e2e/checkout-wizard/ with three per-step files and one end-to-end file.
  3. Implement an apiSeedCheckout custom command that calls cy.request to put a cart-and-checkout-session into a known state. (For Sauce Demo, this is cy.sessionLogin + cy.contains("Add to cart").click() shortcuts via app actions or just visiting with cart cookies set.)
  4. Per-step suite for step 2 (overview / payment) — write four it blocks: required-field error, invalid input error, valid input advances, back button returns to step 1 with data preserved.
  5. Refresh-survival test — fill some fields, cy.reload(), assert the values persisted. If they don't, file the bug.
  6. End-to-end happy path — one it that drives all three steps through the UI and asserts the confirmation page. Time it. Compare to running all per-step specs in sequence — the per-step approach is usually 3–5x faster despite covering more cases.
  7. Stretch: add a step-skipping.cy.ts spec that asserts visiting /checkout/overview without a cart redirects to /cart, and visiting /checkout/complete without a checkout session redirects to /checkout/information. These are the tests that catch URL-router bugs nobody thinks to write.

That ends chapter 6. You have the four authentication strategies and the wizard architecture to hang them on. The next chapter (Visual Testing, Accessibility & Reporting) takes the framework into the cross-cutting concerns — screenshots, visual regression, axe, and the report formats CI needs to publish results to humans.

// tip to track lessons you complete and pick up where you left off across devices.