Skip to main content

End-to-End Testing Guide

Philosophy

E2E tests verify critical user journeys through the entire application stack. They provide the highest confidence but require more maintenance. Use them sparingly for the most important flows.

What to Test with E2E

Critical User Journeys

  1. Authentication Flow

    • Sign up → Email verification → First login
    • Login → MFA → Dashboard access
    • Password reset flow
  2. Payment Flows

    • Free trial → Subscription upgrade
    • Adding payment method → Successful charge
    • Subscription cancellation
  3. Core Business Features

    • Team creation → Member invitation → Acceptance
    • Document creation → Sharing → Collaboration
    • Data import → Processing → Export

What NOT to Test with E2E

  • Every possible UI interaction
  • Error messages for every field
  • Design/styling details
  • Features already covered by integration tests

Playwright Setup

Configuration

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 3 : 1,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',

use: {
baseURL: 'http://localhost:3001',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},

projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
// ...
});

Writing E2E Tests

Authentication Flow Test

// e2e/auth/signup-flow.spec.ts
import { test, expect } from '@playwright/test';
import { createTestEmail } from '../helpers/email';

test.describe('User Signup Flow', () => {
test('new user can sign up and access dashboard', async ({ page }) => {
const testEmail = createTestEmail();

// Navigate to signup
await page.goto('/auth/signup');

// Fill signup form
await page.getByLabel('Email').fill(testEmail);
await page.getByLabel('Password').fill('SecurePass123!');
await page.getByLabel('Confirm Password').fill('SecurePass123!');

// Submit form
await page.getByRole('button', { name: 'Create Account' }).click();

// Verify email sent
await expect(page.getByText('Check your email')).toBeVisible();

// Simulate email verification (in real test, check email service)
const verificationLink = await getVerificationLink(testEmail);
await page.goto(verificationLink);

// Should redirect to dashboard after verification
await expect(page).toHaveURL('/home');
await expect(page.getByText('Welcome to Your Dashboard')).toBeVisible();
});

test('signup shows validation errors', async ({ page }) => {
await page.goto('/auth/signup');

// Try to submit empty form
await page.getByRole('button', { name: 'Create Account' }).click();

// Check validation messages
await expect(page.getByText('Email is required')).toBeVisible();
await expect(page.getByText('Password is required')).toBeVisible();

// Test weak password
await page.getByLabel('Password').fill('weak');
await page.getByRole('button', { name: 'Create Account' }).click();

await expect(
page.getByText('Password must be at least 8 characters')
).toBeVisible();
});
});

Team Management Flow

// e2e/teams/team-management.spec.ts
test.describe('Team Management', () => {
test.use({ storageState: 'e2e/.auth/team-owner.json' });

test('owner can create team and invite members', async ({ page }) => {
// Create new team
await page.goto('/home');
await page.getByRole('button', { name: 'Create Team' }).click();

await page.getByLabel('Team Name').fill('E2E Test Team');
await page.getByLabel('Team Slug').fill('e2e-test-team');
await page.getByRole('button', { name: 'Create' }).click();

// Verify team created
await expect(page).toHaveURL('/home/e2e-test-team');
await expect(page.getByText('E2E Test Team')).toBeVisible();

// Invite team member
await page.getByRole('link', { name: 'Team Settings' }).click();
await page.getByRole('tab', { name: 'Members' }).click();
await page.getByRole('button', { name: 'Invite Member' }).click();

await page.getByLabel('Email').fill('newmember@example.com');
await page.getByRole('combobox', { name: 'Role' }).selectOption('member');
await page.getByRole('button', { name: 'Send Invitation' }).click();

// Verify invitation sent
await expect(
page.getByText('Invitation sent to newmember@example.com')
).toBeVisible();
});

test('member has restricted access', async ({ page, context }) => {
// Login as member
await loginAsUser(page, TEST_USERS.member.email, TEST_USERS.member.password);

// Navigate to team
await page.goto('/home/test-team');

// Verify can't access admin features
await page.getByRole('link', { name: 'Team Settings' }).click();

// Billing tab should not be visible
await expect(
page.getByRole('tab', { name: 'Billing' })
).not.toBeVisible();

// Can't delete team
await expect(
page.getByRole('button', { name: 'Delete Team' })
).not.toBeVisible();
});
});

Subscription Flow

// e2e/billing/subscription-flow.spec.ts
test.describe('Subscription Flow', () => {
test('team can upgrade from free to pro plan', async ({ page }) => {
await loginAsUser(page, TEST_USERS.teamOwner.email, TEST_USERS.teamOwner.password);

// Navigate to billing
await page.goto('/home/test-team/billing');

// Current plan should be free
await expect(page.getByText('Current Plan: Free')).toBeVisible();

// Click upgrade
await page.getByRole('button', { name: 'Upgrade to Pro' }).click();

// Fill payment form (using Stripe test card)
const stripeFrame = page.frameLocator('iframe[name^="__privateStripeFrame"]');
await stripeFrame.getByPlaceholder('Card number').fill('4242424242424242');
await stripeFrame.getByPlaceholder('MM / YY').fill('12/30');
await stripeFrame.getByPlaceholder('CVC').fill('123');

// Complete purchase
await page.getByRole('button', { name: 'Subscribe - $99/month' }).click();

// Wait for confirmation
await expect(page.getByText('Successfully upgraded to Pro!')).toBeVisible({
timeout: 10000 // Payment processing can take time
});

// Verify plan updated
await expect(page.getByText('Current Plan: Pro')).toBeVisible();
});
});

Page Object Model

For complex flows, use Page Object Model to reduce duplication:

// e2e/pages/dashboard.page.ts
export class DashboardPage {
constructor(private page: Page) {}

async goto() {
await this.page.goto('/home');
}

async createNote(title: string, content: string) {
await this.page.getByRole('button', { name: 'New Note' }).click();
await this.page.getByLabel('Title').fill(title);
await this.page.getByLabel('Content').fill(content);
await this.page.getByRole('button', { name: 'Save' }).click();
}

async expectNoteVisible(title: string) {
await expect(
this.page.getByRole('article', { name: title })
).toBeVisible();
}

async deleteNote(title: string) {
await this.page
.getByRole('article', { name: title })
.getByRole('button', { name: 'Delete' })
.click();

await this.page.getByRole('button', { name: 'Confirm Delete' }).click();
}
}

// e2e/notes/notes-crud.spec.ts
import { DashboardPage } from '../pages/dashboard.page';

test('user can create and delete notes', async ({ page }) => {
const dashboard = new DashboardPage(page);

await dashboard.goto();
await dashboard.createNote('Test Note', 'This is a test note');
await dashboard.expectNoteVisible('Test Note');
await dashboard.deleteNote('Test Note');

await expect(page.getByText('Note deleted')).toBeVisible();
});

Remember: E2E tests are expensive to maintain. Focus on critical paths that would cause significant business impact if broken. For everything else, rely on integration and unit tests.