Skip to the code
cf.

Multi-step checkout form

HardForms

Build App({ onSubmit }): a tea shop's checkout in three steps, Details, Address, and Review. The starter gives onSubmit a default so the Preview works; the tests pass their own.

At the top, an ordered list labelled Progress (<ol aria-label="Progress">) has three list items: Details, Address, and Review. The current step's item has aria-current="step", and the others don't.

Details has text boxes labelled Name and Email. Address has Street, City, and Postcode. Only the current step's fields are on the page. Review shows three lines built from the trimmed values: Name: Asha, Email: asha@tea.shop, and Address: 12 Leaf Lane, Darjeeling, 734101.

Details and Address have a Next button. Address and Review have a Back button; Details has none. Pressing Enter in a field does the same as Next.

Next checks the current step's fields, trimmed: every field must be filled in, and the email must be some text, an @, and more text, with no spaces. If anything is wrong, it stays on the step and shows each problem next to its field: Enter your name., Enter a valid email., Enter your street., Enter your city., or Enter your postcode. After that, the messages follow what's typed, so fixing a field removes its message. Messages appear only once Next has been pressed on the step, and start hidden again whenever you move to another step.

Back goes to the step before without checking anything. Every value typed is kept, on every step, whichever way you move.

Review has a Place order button. It calls onSubmit(data) once with { name, email, street, city, postcode }, all trimmed, and then replaces the form with Thanks, Asha!, using the name.

<form>
  <ol aria-label="Progress">
    <li>Details</li>
    <li aria-current="step">Address</li>
    <li>Review</li>
  </ol>
  <label for="street">Street</label> <input id="street" />
  <!-- City and Postcode the same way -->
  <button type="button">Back</button>
  <button>Next</button>
</form>

What the tests check

  1. starts on Details, marked as the current step, with no Back button
    render(<App onSubmit={spy()} />);
    const steps = within(screen.getByRole("list", { name: "Progress" })).getAllByRole("listitem");
    expect(steps.map((step) => step.textContent)).toEqual(["Details", "Address", "Review"]);
    expect(steps[0]).toHaveAttribute("aria-current", "step");
    expect(steps[1]).not.toHaveAttribute("aria-current", "step");
    expect(steps[2]).not.toHaveAttribute("aria-current", "step");
    expect(screen.getByLabelText("Name")).toBeInTheDocument();
    expect(screen.getByLabelText("Email")).toBeInTheDocument();
    expect(screen.queryByLabelText("Street")).not.toBeInTheDocument();
    expect(screen.queryByRole("button", { name: "Back" })).not.toBeInTheDocument();
    
  2. Next with empty fields shows the messages and stays on Details
    render(<App onSubmit={spy()} />);
    expect(screen.queryByText("Enter your name.")).not.toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "Next" }));
    expect(screen.getByText("Enter your name.")).toBeInTheDocument();
    expect(screen.getByText("Enter a valid email.")).toBeInTheDocument();
    expect(screen.getByLabelText("Name")).toBeInTheDocument();
    expect(screen.queryByLabelText("Street")).not.toBeInTheDocument();
    
  3. a message goes away as soon as its field is fixed
    render(<App onSubmit={spy()} />);
    await user.type(screen.getByLabelText("Email"), "asha");
    await user.click(screen.getByRole("button", { name: "Next" }));
    expect(screen.getByText("Enter a valid email.")).toBeInTheDocument();
    await user.type(screen.getByLabelText("Name"), "Asha");
    expect(screen.queryByText("Enter your name.")).not.toBeInTheDocument();
    expect(screen.getByText("Enter a valid email.")).toBeInTheDocument();
    await user.type(screen.getByLabelText("Email"), "@tea.shop");
    expect(screen.queryByText("Enter a valid email.")).not.toBeInTheDocument();
    
  4. valid details move on to Address
    render(<App onSubmit={spy()} />);
    await user.type(screen.getByLabelText("Name"), "Asha");
    await user.type(screen.getByLabelText("Email"), "asha@tea.shop");
    await user.click(screen.getByRole("button", { name: "Next" }));
    const steps = within(screen.getByRole("list", { name: "Progress" })).getAllByRole("listitem");
    expect(steps[1]).toHaveAttribute("aria-current", "step");
    expect(steps[0]).not.toHaveAttribute("aria-current", "step");
    expect(screen.getByLabelText("Street")).toBeInTheDocument();
    expect(screen.getByLabelText("City")).toBeInTheDocument();
    expect(screen.getByLabelText("Postcode")).toBeInTheDocument();
    expect(screen.queryByLabelText("Name")).not.toBeInTheDocument();
    expect(screen.queryByText("Enter your street.")).not.toBeInTheDocument();
    
  5. pressing Enter in a field works like Next
    render(<App onSubmit={spy()} />);
    await user.type(screen.getByLabelText("Name"), "Asha");
    await user.type(screen.getByLabelText("Email"), "asha@tea.shop{Enter}");
    expect(screen.getByLabelText("Street")).toBeInTheDocument();
    
  6. Address checks its own fields before moving on
    render(<App onSubmit={spy()} />);
    await user.type(screen.getByLabelText("Name"), "Asha");
    await user.type(screen.getByLabelText("Email"), "asha@tea.shop");
    await user.click(screen.getByRole("button", { name: "Next" }));
    await user.type(screen.getByLabelText("City"), "   ");
    await user.click(screen.getByRole("button", { name: "Next" }));
    expect(screen.getByText("Enter your street.")).toBeInTheDocument();
    expect(screen.getByText("Enter your city.")).toBeInTheDocument();
    expect(screen.getByText("Enter your postcode.")).toBeInTheDocument();
    expect(screen.getByLabelText("Street")).toBeInTheDocument();
    
  7. Back keeps every value, going either way
    render(<App onSubmit={spy()} />);
    await user.type(screen.getByLabelText("Name"), "Asha");
    await user.type(screen.getByLabelText("Email"), "asha@tea.shop");
    await user.click(screen.getByRole("button", { name: "Next" }));
    await user.type(screen.getByLabelText("Street"), "12 Leaf Lane");
    await user.click(screen.getByRole("button", { name: "Back" }));
    const steps = within(screen.getByRole("list", { name: "Progress" })).getAllByRole("listitem");
    expect(steps[0]).toHaveAttribute("aria-current", "step");
    expect(screen.getByLabelText("Name")).toHaveValue("Asha");
    expect(screen.getByLabelText("Email")).toHaveValue("asha@tea.shop");
    await user.click(screen.getByRole("button", { name: "Next" }));
    expect(screen.getByLabelText("Street")).toHaveValue("12 Leaf Lane");
    expect(screen.queryByText("Enter your city.")).not.toBeInTheDocument();
    
  8. Review shows the trimmed values, and Back returns to Address
    render(<App onSubmit={spy()} />);
    await user.type(screen.getByLabelText("Name"), " Asha ");
    await user.type(screen.getByLabelText("Email"), "asha@tea.shop ");
    await user.click(screen.getByRole("button", { name: "Next" }));
    await user.type(screen.getByLabelText("Street"), "12 Leaf Lane");
    await user.type(screen.getByLabelText("City"), " Darjeeling");
    await user.type(screen.getByLabelText("Postcode"), "734101");
    await user.click(screen.getByRole("button", { name: "Next" }));
    const steps = within(screen.getByRole("list", { name: "Progress" })).getAllByRole("listitem");
    expect(steps[2]).toHaveAttribute("aria-current", "step");
    expect(screen.getByText("Name: Asha")).toBeInTheDocument();
    expect(screen.getByText("Email: asha@tea.shop")).toBeInTheDocument();
    expect(screen.getByText("Address: 12 Leaf Lane, Darjeeling, 734101")).toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "Back" }));
    expect(screen.getByLabelText("City")).toHaveValue(" Darjeeling");
    
  9. Place order calls onSubmit once with the trimmed data and says thanks
    const onSubmit = spy();
    render(<App onSubmit={onSubmit} />);
    await user.type(screen.getByLabelText("Name"), "Ravi ");
    await user.type(screen.getByLabelText("Email"), "ravi@chai.house");
    await user.click(screen.getByRole("button", { name: "Next" }));
    await user.type(screen.getByLabelText("Street"), "4 Spice Road");
    await user.type(screen.getByLabelText("City"), "Munnar");
    await user.type(screen.getByLabelText("Postcode"), " 685612 ");
    await user.click(screen.getByRole("button", { name: "Next" }));
    expect(onSubmit).not.toHaveBeenCalled();
    await user.click(screen.getByRole("button", { name: "Place order" }));
    expect(onSubmit).toHaveBeenCalledTimes(1);
    expect(onSubmit).toHaveBeenCalledWith({ name: "Ravi", email: "ravi@chai.house", street: "4 Spice Road", city: "Munnar", postcode: "685612" });
    expect(screen.getByText("Thanks, Ravi!")).toBeInTheDocument();
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).