Multi-Step Form Testing in Playwright React: Complete Guide
Multi-step forms (also called wizards or workflows) are common in React applications—think signup flows, checkout processes, or survey questionnaires. Testing them end-to-end is trickier than single-page forms because you must verify that data persists across steps, navigation works correctly, and validation is enforced at each stage. This guide covers strategies for testing multi-step forms with Playwright, including state verification, backward navigation, and error recovery.
Understanding Multi-Step Form Architecture
A typical React multi-step form has these characteristics: a single form component managing overall state, multiple step components (displayed conditionally), navigation buttons (Next, Previous, Submit), and validation that might be per-step or form-wide. Let's consider a signup wizard:
// Example React component structure (for reference)
<SignupWizard>
{currentStep === 1 && <PersonalInfoStep />}
{currentStep === 2 && <AddressStep />}
{currentStep === 3 && <ConfirmationStep />}
<Navigation />
</SignupWizard>
When testing this, you need to verify that: (1) each step displays and collects the right data, (2) the Next button only enables when the step is valid, (3) clicking Next advances the form and preserves previous data, (4) clicking Previous goes back without losing data, and (5) form submission succeeds only when all steps are complete.
Testing Step Progression and Data Preservation
The most common multi-step form test: fill step 1, advance to step 2, verify step 1 data is preserved, fill step 2, advance to step 3, and submit:
import { test, expect } from '@playwright/test';
test('user can complete a three-step signup form', async ({ page }) => {
await page.goto('/signup');
// Step 1: Personal Information
await expect(page.locator('h2')).toContainText('Personal Information');
await page.locator('[data-testid="firstName"]').fill('John');
await page.locator('[data-testid="lastName"]').fill('Doe');
await page.locator('[data-testid="email"]').fill('[email protected]');
// Verify next button is enabled
const nextButton = page.locator('role=button[name="Next"]');
await expect(nextButton).toBeEnabled();
// Advance to step 2
await nextButton.click();
// Step 2: Address Information
await expect(page.locator('h2')).toContainText('Address');
// Verify step 1 data persists (check the form still has values)
// In a real scenario, the previous step might be hidden but data remains in state
// If the form shows a summary: verify it
const summaryName = page.locator('[data-testid="summary-name"]');
await expect(summaryName).toContainText('John Doe');
// Fill step 2
await page.locator('[data-testid="street"]').fill('123 Main St');
await page.locator('[data-testid="city"]').fill('Springfield');
await page.locator('[data-testid="zip"]').fill('12345');
// Advance to step 3
await nextButton.click();
// Step 3: Confirmation
await expect(page.locator('h2')).toContainText('Confirmation');
// Verify all data is shown in the confirmation step
await expect(page.locator('[data-testid="confirm-name"]')).toContainText('John Doe');
await expect(page.locator('[data-testid="confirm-address"]')).toContainText('123 Main St');
// Submit the form
const submitButton = page.locator('role=button[name="Submit"]');
await submitButton.click();
// Verify success redirect
await expect(page).toHaveURL('/signup/success');
await expect(page.locator('h1')).toContainText('Account Created');
});
This test exercises the entire user journey. If it passes, you know data flows correctly through all steps.
Testing Backward Navigation and State Integrity
Many users click Previous to correct information. Verify that your form handles this correctly:
test('user can navigate backward and edit previous steps', async ({ page }) => {
await page.goto('/signup');
// Step 1: Fill and advance
await page.locator('[data-testid="firstName"]').fill('Jane');
await page.locator('[data-testid="lastName"]').fill('Smith');
await page.locator('[data-testid="email"]').fill('[email protected]');
await page.locator('role=button[name="Next"]').click();
// Step 2: Fill and advance
await page.locator('[data-testid="street"]').fill('456 Oak Ave');
await page.locator('[data-testid="city"]').fill('Shelbyville');
await page.locator('role=button[name="Next"]').click();
// Step 3: Go back to Step 2
await page.locator('role=button[name="Previous"]').click();
// Verify Step 2 data is preserved
await expect(page.locator('[data-testid="street"]')).toHaveValue('456 Oak Ave');
await expect(page.locator('[data-testid="city"]')).toHaveValue('Shelbyville');
// Edit the address
await page.locator('[data-testid="city"]').fill('Capital City');
// Go forward again
await page.locator('role=button[name="Next"]').click();
// Verify the updated address is shown in step 3
await expect(page.locator('[data-testid="confirm-address"]')).toContainText('Capital City');
});
This test catches a common bug: when navigating backward, some forms don't preserve edited data, forcing users to re-enter information.
Testing Per-Step Validation
Each step often has its own validation rules. Verify that invalid data prevents advancing:
test('form prevents advancing with invalid step data', async ({ page }) => {
await page.goto('/signup');
// Try to advance without filling required fields
const nextButton = page.locator('role=button[name="Next"]');
// Verify next button is disabled initially
await expect(nextButton).toBeDisabled();
// Fill only first name (incomplete)
await page.locator('[data-testid="firstName"]').fill('John');
// Next button should still be disabled
await expect(nextButton).toBeDisabled();
// Fill remaining required fields
await page.locator('[data-testid="lastName"]').fill('Doe');
await page.locator('[data-testid="email"]').fill('[email protected]');
// Now next button should be enabled
await expect(nextButton).toBeEnabled();
});
test('form shows validation errors for invalid email', async ({ page }) => {
await page.goto('/signup');
await page.locator('[data-testid="firstName"]').fill('John');
await page.locator('[data-testid="lastName"]').fill('Doe');
await page.locator('[data-testid="email"]').fill('not-an-email'); // invalid
// Click next to trigger validation
await page.locator('role=button[name="Next"]').click();
// Verify error message appears
const errorMsg = page.locator('[data-testid="email-error"]');
await expect(errorMsg).toBeVisible();
await expect(errorMsg).toContainText('Please enter a valid email');
// Verify we're still on step 1
await expect(page.locator('h2')).toContainText('Personal Information');
});
Validation testing prevents silent failures where users submit invalid data.
Testing Form Submission and Server-Side Validation
After filling all steps, the form submits to the server. Test the entire submission flow:
test('form submission succeeds with all valid data', async ({ page }) => {
await page.goto('/signup');
// Step 1
await page.locator('[data-testid="firstName"]').fill('Alice');
await page.locator('[data-testid="lastName"]').fill('Johnson');
await page.locator('[data-testid="email"]').fill('[email protected]');
await page.locator('role=button[name="Next"]').click();
// Step 2
await page.locator('[data-testid="street"]').fill('789 Pine St');
await page.locator('[data-testid="city"]').fill('Capital City');
await page.locator('[data-testid="zip"]').fill('54321');
await page.locator('role=button[name="Next"]').click();
// Step 3: Submit
// Wait for the API response to avoid race conditions
const submitPromise = page.waitForResponse(
response => response.url().includes('/api/signup') && response.status() === 201
);
await page.locator('role=button[name="Submit"]').click();
await submitPromise; // wait for the network request
// Verify success
await expect(page).toHaveURL('/signup/success');
});
test('form handles server-side validation errors', async ({ page }) => {
await page.goto('/signup');
// Fill all steps
await page.locator('[data-testid="firstName"]').fill('Bob');
await page.locator('[data-testid="lastName"]').fill('Test');
await page.locator('[data-testid="email"]').fill('[email protected]'); // email already registered
await page.locator('role=button[name="Next"]').click();
await page.locator('[data-testid="street"]').fill('100 Test Ave');
await page.locator('[data-testid="city"]').fill('Test City');
await page.locator('[data-testid="zip"]').fill('00000');
await page.locator('role=button[name="Next"]').click();
// Submit
await page.locator('role=button[name="Submit"]').click();
// Expect error message from server
const errorMsg = page.locator('[data-testid="form-error"]');
await expect(errorMsg).toBeVisible();
await expect(errorMsg).toContainText('Email already registered');
// Should remain on step 3 (or redirect back to edit email)
// depending on your UX
});
Server-side validation testing is crucial because attackers can bypass client-side validation via the DevTools console.
Testing State Persistence Across Page Reloads
In a single-page app, reload shouldn't lose form data (assuming you save to localStorage or sessionStorage):
test('form data persists after page reload', async ({ page }) => {
await page.goto('/signup');
// Fill step 1
await page.locator('[data-testid="firstName"]').fill('Chris');
await page.locator('[data-testid="lastName"]').fill('Martin');
await page.locator('[data-testid="email"]').fill('[email protected]');
await page.locator('role=button[name="Next"]').click();
// Verify we're on step 2
await expect(page.locator('h2')).toContainText('Address');
// Reload the page
await page.reload();
// Verify we're still on step 2 (not reset to step 1)
await expect(page.locator('h2')).toContainText('Address');
// Verify step 1 data is preserved
const summaryName = page.locator('[data-testid="summary-name"]');
await expect(summaryName).toContainText('Chris Martin');
});
This test catches data loss bugs that occur when forms rely on JavaScript state that gets wiped on reload.
Testing Dynamic Steps or Conditional Logic
Some forms show different steps based on answers (e.g., "Do you have dependents?" → show dependent info step). Test this:
test('conditional step appears based on user choice', async ({ page }) => {
await page.goto('/survey');
// Step 1: Ask if user has dependents
await page.locator('[data-testid="has-dependents-yes"]').click();
await page.locator('role=button[name="Next"]').click();
// Dependent info step should appear
await expect(page.locator('h2')).toContainText('Dependent Information');
await page.locator('[data-testid="dependent-count"]').selectOption('2');
await page.locator('role=button[name="Next"]').click();
// Final step
await expect(page.locator('h2')).toContainText('Summary');
// Verify dependent count is shown
await expect(page.locator('[data-testid="confirm-dependents"]')).toContainText('2');
});
test('conditional step is skipped when condition is false', async ({ page }) => {
await page.goto('/survey');
// Select "No dependents"
await page.locator('[data-testid="has-dependents-no"]').click();
await page.locator('role=button[name="Next"]').click();
// Should skip to final step (not show dependent info step)
await expect(page.locator('h2')).toContainText('Summary');
});
This test ensures conditional logic is correctly implemented—a critical feature for complex forms.
Key Takeaways
- Multi-step forms require testing each step, verifying data persistence across steps, and ensuring navigation works bidirectionally.
- Test per-step validation to prevent advancing with incomplete or invalid data.
- Test form submission with correct data and server-side error handling.
- Verify page reloads don't lose form state (use localStorage or sessionStorage).
- Test conditional steps that appear/disappear based on user choices.
Frequently Asked Questions
How do I test forms with async field validation (checking if email is unique)?
Use page.waitForResponse() to wait for the validation API call:
await page.locator('[data-testid="email"]').fill('[email protected]');
const validationPromise = page.waitForResponse(
response => response.url().includes('/api/validate-email')
);
await validationPromise;
// Now the validation is complete
Should I test every combination of step sequences?
For simple sequential flows, test the happy path plus backward navigation. For complex conditional flows with many branches, consider a matrix (combinations of choices). In practice, 5–10 tests per form is reasonable.
How do I test file uploads in multi-step forms?
Use page.locator('input[type="file"]').setInputFiles(path) to simulate a file selection. If the form uploads the file immediately, wait for the upload API response before advancing.
What if my form saves to the backend at each step, not just on final submit?
Test that each step's save API is called correctly. Use page.waitForResponse() or network mocking to verify the request payload and status.
How do I test accessibility in multi-step forms?
Use role-based locators (role=button[name="Next"] instead of .next-btn) and verify that form errors are announced to screen readers via aria-live or aria-describedby. Test with --debug mode and check the accessibility tree in DevTools.