supaguardsupaguardDocs
Use cases

Multi-Step Form Monitoring: Test Complex Form Flows

Monitor multi-step forms, wizards, and onboarding flows with Playwright. Catch validation errors, step navigation issues, and submission failures before users encounter them.

Multi-step forms—whether for user onboarding, applications, or surveys—are complex pieces of UI with many failure points. This guide shows you how to monitor them effectively with supaguard.

Why Multi-Step Forms Break

Multi-step forms have unique failure modes:

IssueImpact
Step navigation brokenUsers stuck, can't proceed
Validation errors unclearUsers can't fix issues
Data not persisted between stepsUsers lose progress
Back button breaksUsers can't correct mistakes
Final submission failsEntire form effort wasted

Basic Multi-Step Form Test

Here's a complete test for a typical multi-step form:

import { test, expect } from "@playwright/test";

test("complete onboarding wizard", async ({ page }) => {
  await page.goto("https://app.example.com/onboarding");

  // Step 1: Personal Information
  await expect(page.getByText("Step 1 of 4")).toBeVisible();
  await page.getByLabel("First Name").fill("Test");
  await page.getByLabel("Last Name").fill("User");
  await page.getByLabel("Email").fill("test@example.com");
  await page.getByRole("button", { name: "Continue" }).click();

  // Step 2: Company Information
  await expect(page.getByText("Step 2 of 4")).toBeVisible();
  await page.getByLabel("Company Name").fill("Test Company");
  await page.getByLabel("Company Size").selectOption("10-50");
  await page.getByLabel("Industry").selectOption("Technology");
  await page.getByRole("button", { name: "Continue" }).click();

  // Step 3: Preferences
  await expect(page.getByText("Step 3 of 4")).toBeVisible();
  await page.getByLabel("Notifications").check();
  await page.getByLabel("Weekly digest").check();
  await page.getByRole("button", { name: "Continue" }).click();

  // Step 4: Review and Submit
  await expect(page.getByText("Step 4 of 4")).toBeVisible();
  await expect(page.getByText("Test User")).toBeVisible();
  await expect(page.getByText("Test Company")).toBeVisible();
  await page.getByRole("button", { name: "Complete Setup" }).click();

  // Verify completion
  await expect(page.getByText("Welcome to")).toBeVisible();
  await expect(page).toHaveURL(/.*dashboard/);
});

Testing Step Navigation

Forward Navigation

test("can navigate forward through steps", async ({ page }) => {
  await page.goto("/onboarding");

  // Step 1 → Step 2
  await page.getByLabel("First Name").fill("Test");
  await page.getByRole("button", { name: "Next" }).click();
  await expect(page.getByTestId("step-indicator")).toHaveText("2 / 4");

  // Step 2 → Step 3
  await page.getByLabel("Company").fill("Test Co");
  await page.getByRole("button", { name: "Next" }).click();
  await expect(page.getByTestId("step-indicator")).toHaveText("3 / 4");
});

Back Button

test("can navigate back without losing data", async ({ page }) => {
  await page.goto("/onboarding");

  // Complete step 1
  await page.getByLabel("First Name").fill("Test");
  await page.getByLabel("Last Name").fill("User");
  await page.getByRole("button", { name: "Next" }).click();

  // Go to step 2
  await expect(page.getByText("Step 2")).toBeVisible();
  await page.getByLabel("Company").fill("Test Company");

  // Go back
  await page.getByRole("button", { name: "Back" }).click();

  // Verify step 1 data is preserved
  await expect(page.getByLabel("First Name")).toHaveValue("Test");
  await expect(page.getByLabel("Last Name")).toHaveValue("User");
});

Browser Back Button

test("browser back button works correctly", async ({ page }) => {
  await page.goto("/onboarding");

  // Complete step 1
  await page.getByLabel("First Name").fill("Test");
  await page.getByRole("button", { name: "Next" }).click();
  await expect(page.getByText("Step 2")).toBeVisible();

  // Use browser back
  await page.goBack();

  // Should be on step 1 with data preserved
  await expect(page.getByText("Step 1")).toBeVisible();
  await expect(page.getByLabel("First Name")).toHaveValue("Test");
});

Testing Validation

Required Field Validation

test("shows validation errors for required fields", async ({ page }) => {
  await page.goto("/onboarding");

  // Try to proceed without filling required fields
  await page.getByRole("button", { name: "Next" }).click();

  // Should show validation errors
  await expect(page.getByText("First name is required")).toBeVisible();
  await expect(page.getByText("Email is required")).toBeVisible();

  // Should still be on step 1
  await expect(page.getByText("Step 1")).toBeVisible();
});

Field Format Validation

test("validates email format", async ({ page }) => {
  await page.goto("/onboarding");

  await page.getByLabel("Email").fill("invalid-email");
  await page.getByRole("button", { name: "Next" }).click();

  await expect(page.getByText("Please enter a valid email")).toBeVisible();

  // Fix the error
  await page.getByLabel("Email").clear();
  await page.getByLabel("Email").fill("valid@example.com");
  await page.getByRole("button", { name: "Next" }).click();

  // Should proceed to next step
  await expect(page.getByText("Step 2")).toBeVisible();
});

Cross-Field Validation

test("validates password confirmation matches", async ({ page }) => {
  await page.goto("/signup");

  await page.getByLabel("Password").fill("MyPassword123!");
  await page.getByLabel("Confirm Password").fill("DifferentPassword!");
  await page.getByRole("button", { name: "Continue" }).click();

  await expect(page.getByText("Passwords do not match")).toBeVisible();
});

Testing Data Persistence

Session Persistence

test("form data persists after page reload", async ({ page }) => {
  await page.goto("/onboarding");

  // Fill step 1
  await page.getByLabel("First Name").fill("Test");
  await page.getByLabel("Last Name").fill("User");

  // Reload page
  await page.reload();

  // Data should still be there (if your form saves to session/localStorage)
  await expect(page.getByLabel("First Name")).toHaveValue("Test");
  await expect(page.getByLabel("Last Name")).toHaveValue("User");
});

Progress Indicator

test("progress indicator reflects current step", async ({ page }) => {
  await page.goto("/onboarding");

  // Step 1: indicator shows step 1 active
  await expect(page.getByTestId("step-1-indicator")).toHaveClass(/active/);
  await expect(page.getByTestId("step-2-indicator")).not.toHaveClass(/active/);

  // Proceed to step 2
  await page.getByLabel("First Name").fill("Test");
  await page.getByRole("button", { name: "Next" }).click();

  // Indicator should update
  await expect(page.getByTestId("step-1-indicator")).toHaveClass(/completed/);
  await expect(page.getByTestId("step-2-indicator")).toHaveClass(/active/);
});

Handling Dynamic Forms

Conditional Steps

Some forms show/hide steps based on previous answers:

test("shows correct steps based on selection", async ({ page }) => {
  await page.goto("/application");

  // Step 1: Select type
  await page.getByLabel("I am a").selectOption("business");
  await page.getByRole("button", { name: "Next" }).click();

  // Business users should see company info step
  await expect(page.getByText("Company Information")).toBeVisible();

  // Go back and change selection
  await page.getByRole("button", { name: "Back" }).click();
  await page.getByLabel("I am a").selectOption("individual");
  await page.getByRole("button", { name: "Next" }).click();

  // Individuals should NOT see company info
  await expect(page.getByText("Company Information")).not.toBeVisible();
  await expect(page.getByText("Personal Details")).toBeVisible();
});

Dynamic Field Addition

test("can add multiple items", async ({ page }) => {
  await page.goto("/form");

  // Add first item
  await page.getByLabel("Item 1 Name").fill("First Item");

  // Click to add another
  await page.getByRole("button", { name: "Add another item" }).click();

  // New field should appear
  await expect(page.getByLabel("Item 2 Name")).toBeVisible();
  await page.getByLabel("Item 2 Name").fill("Second Item");

  // Verify both are submitted
  await page.getByRole("button", { name: "Submit" }).click();
  await expect(page.getByText("2 items submitted")).toBeVisible();
});

Testing File Uploads

test("can upload documents in form", async ({ page }) => {
  await page.goto("/application");

  // Navigate to document upload step
  // ... previous steps ...

  // Upload file
  const fileInput = page.getByLabel("Upload ID");
  await fileInput.setInputFiles("./test-files/sample-id.pdf");

  // Verify upload success
  await expect(page.getByText("sample-id.pdf")).toBeVisible();
  await expect(page.getByText("Upload complete")).toBeVisible();

  // Proceed
  await page.getByRole("button", { name: "Continue" }).click();
});

Error Recovery

Network Error During Submission

test("handles submission error gracefully", async ({ page }) => {
  await page.goto("/onboarding");

  // Complete all steps...

  // Intercept the final submission to simulate error
  await page.route("**/api/onboarding", (route) =>
    route.fulfill({ status: 500, body: "Server error" })
  );

  await page.getByRole("button", { name: "Complete Setup" }).click();

  // Should show error message
  await expect(page.getByText(/something went wrong|try again/i)).toBeVisible();

  // Data should still be in form
  await expect(page.getByRole("button", { name: "Complete Setup" })).toBeEnabled();
});

Monitoring Configuration

Check Frequency

Form TypeFrequency
User signup/onboardingEvery 5 minutes
Lead generation formsEvery 10 minutes
Application formsEvery 15 minutes
SurveysEvery 30 minutes

Alert Priority

Form failures should generally be Critical if they:

  • Block user registration
  • Prevent purchases
  • Stop lead capture

Set up appropriate alert policies based on business impact.

Common Issues

Animation Timing

// Wait for step transition animation to complete
await page.getByRole("button", { name: "Next" }).click();
await page.waitForTimeout(300); // If transition takes 300ms
await expect(page.getByText("Step 2")).toBeVisible();

Loading States

// Wait for async validation to complete
await page.getByLabel("Email").fill("test@example.com");
await page.getByLabel("Email").blur(); // Trigger validation
await page.waitForSelector('[data-testid="email-valid"]');
await page.getByRole("button", { name: "Next" }).click();

Autofill Conflicts

// Clear autofill before typing
await page.getByLabel("Email").clear();
await page.getByLabel("Email").fill("test@example.com");

On this page