Testing SaaS Onboarding Flows: From Signup to First Value

Testing SaaS Onboarding Flows: From Signup to First Value

The onboarding flow is the most consequential part of a SaaS application to test. It is the first experience every new user has with your product, it determines activation rates, and bugs in it silently kill trials before they convert. Unlike most application features where a bug inconveniences an existing user, a broken onboarding flow means a prospect never becomes a user at all — and they never report the bug because they left.

Despite this, onboarding is one of the most under-tested areas in most SaaS products. This guide covers everything from signup funnel steps to email verification, trial period logic, activation events, and the multi-step onboarding wizard. All examples use Playwright, with notes on Cypress equivalents.

Setting Up Onboarding Tests

Onboarding tests are inherently stateful and sequential. A user must complete step one before step two becomes available. This makes test setup critical — you need helpers that can put the system into a specific state reliably.

// playwright.config.js
const { defineConfig } = require('@playwright/test');

module.exports = defineConfig({
  testDir: './tests/onboarding',
  use: {
    baseURL: process.env.TEST_BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  timeout: 30000,
});
// helpers/onboarding.js
const { expect } = require('@playwright/test');

async function generateTestEmail() {
  // Use a real mailbox for email verification tests
  // or a mock email service like Mailhog/MailSlurp
  const timestamp = Date.now();
  return `test+${timestamp}@yourtestdomain.com`;
}

async function completeSignup(page, email, password) {
  await page.goto('/signup');
  await page.fill('[data-testid="email-input"]', email);
  await page.fill('[data-testid="password-input"]', password);
  await page.fill('[data-testid="name-input"]', 'Test User');
  await page.click('[data-testid="signup-button"]');
  await expect(page).toHaveURL(/\/verify-email|\/onboarding/);
}

async function getVerificationEmailLink(email) {
  // Query your test email service (Mailhog, MailSlurp, etc.)
  const mailhogRes = await fetch(`http://localhost:8025/api/v2/search?kind=to&query=${email}`);
  const mails = await mailhogRes.json();
  const body = mails.items[0].Content.Body;
  const match = body.match(/https?:\/\/[^\s"]+verify[^\s"]+/);
  return match ? match[0] : null;
}

module.exports = { generateTestEmail, completeSignup, getVerificationEmailLink };

Testing the Signup Funnel

The signup funnel has several distinct steps, each of which can fail independently. Test each step in isolation as well as the full happy path.

// tests/onboarding/signup.spec.js
const { test, expect } = require('@playwright/test');
const { generateTestEmail } = require('../helpers/onboarding');

test.describe('Signup Funnel', () => {
  test('happy path: user can complete signup with valid credentials', async ({ page }) => {
    const email = await generateTestEmail();

    await page.goto('/signup');
    await page.fill('[data-testid="email-input"]', email);
    await page.fill('[data-testid="password-input"]', 'SecurePass123!');
    await page.fill('[data-testid="name-input"]', 'Jane Developer');
    await page.click('[data-testid="signup-button"]');

    // Should redirect to email verification or onboarding
    await expect(page).toHaveURL(/\/(verify-email|onboarding)/);
    await expect(page.locator('[data-testid="signup-confirmation"]')).toBeVisible();
  });

  test('should show inline validation for weak passwords', async ({ page }) => {
    await page.goto('/signup');
    await page.fill('[data-testid="email-input"]', 'test@example.com');
    await page.fill('[data-testid="password-input"]', '123');
    await page.click('[data-testid="password-input"]'); // blur
    await page.keyboard.press('Tab');

    await expect(page.locator('[data-testid="password-error"]')).toBeVisible();
    await expect(page.locator('[data-testid="password-error"]')).toContainText(/password/i);
  });

  test('should prevent duplicate account creation', async ({ page }) => {
    const existingEmail = 'existing@example.com'; // Pre-seeded in test DB

    await page.goto('/signup');
    await page.fill('[data-testid="email-input"]', existingEmail);
    await page.fill('[data-testid="password-input"]', 'SecurePass123!');
    await page.fill('[data-testid="name-input"]', 'Duplicate User');
    await page.click('[data-testid="signup-button"]');

    await expect(page.locator('[data-testid="email-error"]')).toBeVisible();
    await expect(page.locator('[data-testid="email-error"]')).toContainText(/already/i);
  });

  test('should preserve form state across page refresh', async ({ page }) => {
    await page.goto('/signup');
    await page.fill('[data-testid="name-input"]', 'Partial User');
    await page.fill('[data-testid="email-input"]', 'partial@example.com');

    await page.reload();

    // Form should either preserve state (with localStorage) or reset cleanly
    // This test documents the expected behavior
    const emailValue = await page.inputValue('[data-testid="email-input"]');
    // Decide: do you want to persist this or not?
    expect([emailValue, '']).toContain(emailValue); // Either persisted or empty, not broken
  });
});

Testing Email Verification Flows

Email verification is a common point of failure. Links expire, tokens get reused, and users click links in the wrong browser. Test all of these scenarios.

test.describe('Email Verification', () => {
  test('should verify email via link and proceed to onboarding', async ({ page, browser }) => {
    const email = await generateTestEmail();
    await completeSignup(page, email, 'SecurePass123!');

    // Get the verification link from the test email service
    const verificationLink = await getVerificationEmailLink(email);
    expect(verificationLink).not.toBeNull();

    await page.goto(verificationLink);
    await expect(page).toHaveURL(/\/onboarding/);
    await expect(page.locator('[data-testid="email-verified-banner"]')).toBeVisible();
  });

  test('should reject expired verification tokens', async ({ page }) => {
    // This token is pre-seeded in the test database as expired
    const expiredToken = 'expired-token-123456';

    await page.goto(`/verify-email?token=${expiredToken}`);

    await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
    await expect(page.locator('[data-testid="error-message"]')).toContainText(/expired/i);
    await expect(page.locator('[data-testid="resend-verification-button"]')).toBeVisible();
  });

  test('should reject already-used verification tokens', async ({ page }) => {
    const email = await generateTestEmail();
    await completeSignup(page, email, 'SecurePass123!');

    const verificationLink = await getVerificationEmailLink(email);

    // Use the link once
    await page.goto(verificationLink);
    await expect(page).toHaveURL(/\/onboarding/);

    // Try to use it again
    await page.goto(verificationLink);
    await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
    await expect(page.locator('[data-testid="error-message"]')).toContainText(/already verified|invalid/i);
  });

  test('should resend verification email when requested', async ({ page }) => {
    const email = await generateTestEmail();
    await completeSignup(page, email, 'SecurePass123!');

    await page.goto('/verify-email');
    await page.click('[data-testid="resend-verification-button"]');

    await expect(page.locator('[data-testid="resend-confirmation"]')).toBeVisible();

    // A second email should arrive
    await page.waitForTimeout(2000);
    const newLink = await getVerificationEmailLink(email);
    expect(newLink).toBeDefined();
  });
});

Testing the Onboarding Wizard

Multi-step onboarding wizards have state machines that are easy to break. Users should not be able to skip required steps, go back to completed steps should work, and refreshing mid-wizard should resume correctly.

test.describe('Onboarding Wizard', () => {
  // Use a pre-verified user state for all wizard tests
  test.use({ storageState: 'playwright/.auth/verified-user.json' });

  test('should display all onboarding steps in correct order', async ({ page }) => {
    await page.goto('/onboarding');

    const steps = page.locator('[data-testid="onboarding-step"]');
    await expect(steps).toHaveCount(4);

    const stepNames = await steps.allTextContents();
    expect(stepNames[0]).toMatch(/profile|personal/i);
    expect(stepNames[1]).toMatch(/workspace|team/i);
    expect(stepNames[2]).toMatch(/invite|collaborators/i);
    expect(stepNames[3]).toMatch(/connect|integration/i);
  });

  test('should not allow skipping required steps', async ({ page }) => {
    await page.goto('/onboarding/step/3');

    // Should redirect back to first incomplete step
    await expect(page).toHaveURL(/\/onboarding\/step\/1/);
  });

  test('should persist progress when navigating back', async ({ page }) => {
    await page.goto('/onboarding/step/1');
    await page.fill('[data-testid="workspace-name"]', 'My Workspace');
    await page.click('[data-testid="next-step-button"]');

    await expect(page).toHaveURL(/\/onboarding\/step\/2/);

    await page.click('[data-testid="back-button"]');

    await expect(page).toHaveURL(/\/onboarding\/step\/1/);
    await expect(page.locator('[data-testid="workspace-name"]')).toHaveValue('My Workspace');
  });

  test('should resume from correct step on page refresh', async ({ page }) => {
    await page.goto('/onboarding/step/1');
    await page.fill('[data-testid="workspace-name"]', 'Persist Test');
    await page.click('[data-testid="next-step-button"]');
    await expect(page).toHaveURL(/\/onboarding\/step\/2/);

    await page.reload();
    await expect(page).toHaveURL(/\/onboarding\/step\/2/);
  });

  test('should complete onboarding and redirect to dashboard', async ({ page }) => {
    await page.goto('/onboarding/step/1');

    // Step 1: Profile
    await page.fill('[data-testid="workspace-name"]', 'Test Workspace');
    await page.selectOption('[data-testid="team-size"]', '1-10');
    await page.click('[data-testid="next-step-button"]');

    // Step 2: Invite team
    await page.click('[data-testid="skip-invite-button"]'); // optional step

    // Step 3: Integration
    await page.click('[data-testid="skip-integration-button"]'); // optional step

    // Step 4: Confirmation
    await page.click('[data-testid="finish-onboarding-button"]');

    await expect(page).toHaveURL(/\/dashboard/);
    await expect(page.locator('[data-testid="welcome-banner"]')).toBeVisible();
  });
});

Testing Trial Period Logic

Trial periods gate feature access by time. Test the boundary conditions: what happens on day 0, day N, day N+1, and when a trial converts.

test.describe('Trial Period', () => {
  test('should show trial status and days remaining in the UI', async ({ page }) => {
    // Login as a user in day 7 of a 14-day trial
    await page.goto('/dashboard');

    const trialBanner = page.locator('[data-testid="trial-banner"]');
    await expect(trialBanner).toBeVisible();
    await expect(trialBanner).toContainText('7 days');
  });

  test('should show upgrade prompt when trial expires', async ({ page }) => {
    // Login as a user with expired trial
    await page.goto('/dashboard');

    await expect(page.locator('[data-testid="trial-expired-modal"]')).toBeVisible();
    await expect(page.locator('[data-testid="upgrade-button"]')).toBeVisible();

    // User should not be able to dismiss the modal without upgrading or seeing options
    await page.keyboard.press('Escape');
    await expect(page.locator('[data-testid="trial-expired-modal"]')).toBeVisible();
  });

  test('should block pro features after trial expiry', async ({ page }) => {
    await page.goto('/advanced-analytics');

    await expect(page).toHaveURL(/\/upgrade|\/pricing/);
  });
});

Testing Activation Events

Activation events — the moment a user gets their "first value" — are critical product metrics. Test that these events fire correctly and that users are guided toward them.

test.describe('Activation Events', () => {
  test('should track first project creation as activation event', async ({ page }) => {
    const analyticsEvents = [];
    await page.route('**/api/analytics/events', route => {
      route.request().postDataJSON().then(data => analyticsEvents.push(data));
      route.continue();
    });

    await page.goto('/projects/new');
    await page.fill('[data-testid="project-name"]', 'My First Project');
    await page.click('[data-testid="create-project-button"]');

    await page.waitForURL(/\/projects\/.+/);

    const activationEvent = analyticsEvents.find(e => e.event === 'project_created');
    expect(activationEvent).toBeDefined();
    expect(activationEvent.properties.is_first_project).toBe(true);
  });

  test('should show contextual help tips during first-use flow', async ({ page }) => {
    await page.goto('/dashboard');

    // For new users, contextual tooltips should appear
    const tooltip = page.locator('[data-testid="onboarding-tooltip"]');
    await expect(tooltip).toBeVisible();
    await expect(tooltip).toContainText(/create your first/i);

    await page.click('[data-testid="tooltip-dismiss"]');
    await expect(tooltip).not.toBeVisible();

    // Tooltip should not reappear after dismissal
    await page.reload();
    await expect(tooltip).not.toBeVisible();
  });
});

Testing Integrations During Onboarding

Many SaaS products ask users to connect integrations during onboarding. The OAuth flow for third-party services has its own set of failure modes.

test.describe('Integration Connection During Onboarding', () => {
  test('should handle OAuth callback and store integration tokens', async ({ page, context }) => {
    await page.goto('/onboarding/connect-github');
    await page.click('[data-testid="connect-github-button"]');

    // Should open GitHub OAuth page
    const popup = await context.waitForEvent('page');
    await popup.waitForURL(/github\.com\/login\/oauth/);

    // Simulate successful OAuth callback
    await page.goto('/oauth/callback/github?code=test_code&state=test_state');
    await expect(page).toHaveURL(/\/onboarding/);

    const successMsg = page.locator('[data-testid="github-connected"]');
    await expect(successMsg).toBeVisible();
  });

  test('should handle OAuth cancellation gracefully', async ({ page }) => {
    await page.goto('/oauth/callback/github?error=access_denied&state=test_state');

    await expect(page).toHaveURL(/\/onboarding/);
    await expect(page.locator('[data-testid="integration-error"]')).toBeVisible();
    await expect(page.locator('[data-testid="integration-error"]')).toContainText(/cancelled|denied/i);
  });
});

Running Onboarding Tests in CI

Onboarding tests need consistent email infrastructure in CI. Use a deterministic email service like Mailhog (local) or MailSlurp (cloud) rather than real email accounts:

# .github/workflows/e2e.yml
- name: Start Mailhog
  run: docker run -d -p 8025:8025 -p 1025:1025 mailhog/mailhog

- name: Run onboarding tests
  run: npx playwright test tests/onboarding/
  env:
    TEST_BASE_URL: http://localhost:3000
    SMTP_HOST: localhost
    SMTP_PORT: 1025
    EMAIL_TEST_API: http://localhost:8025/api/v2

The onboarding flow is tested once, then forgotten — until it silently breaks and your trial conversion rate drops. Put these tests in CI, run them on every deploy, and treat any failure as a P0 incident. The signup page is your product's front door.

Read more