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/v2The 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.