E2E Testing
End-to-end testing strategies to verify complete user workflows
End-to-end (E2E) tests verify that your application works correctly from a user's perspective, testing complete workflows through the actual UI and real backend services. They sit at the top of the testing pyramid and should be used sparingly for critical user journeys.
What is E2E Testing?
E2E tests simulate real user interactions:
- Navigate through the actual application
- Fill out forms, click buttons, submit data
- Verify the UI reflects expected changes
- Confirm data is persisted correctly
E2E Test Characteristics
| Aspect | Description |
|---|---|
| Scope | Complete application stack |
| Speed | Slow (seconds to minutes per test) |
| Reliability | Can be flaky due to timing, network |
| Maintenance | Higher maintenance than unit tests |
| Value | High confidence in real user workflows |
When to Write E2E Tests
Good Candidates for E2E
- Critical user journeys: Login, checkout, signup
- Complex workflows: Multi-step forms, wizards
- Integration points: Payment processing, third-party auth
- Regulatory requirements: Workflows requiring audit evidence
Skip E2E For
- Edge cases (use unit tests)
- API validation (use integration tests)
- Visual styling (use visual regression tools)
- Every possible permutation
E2E Testing with Playwright
Basic Test Structure
import { test, expect } from '@playwright/test';
test.describe('User Authentication', () => {
test('should login with valid credentials', async ({ page }) => {
// Navigate to login page
await page.goto('/login');
// Fill in credentials
await page.fill('[data-testid="email-input"]', 'user@example.com');
await page.fill('[data-testid="password-input"]', 'SecurePass123!');
// Submit form
await page.click('[data-testid="login-button"]');
// Verify successful login
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('[data-testid="welcome-message"]'))
.toContainText('Welcome, User');
});
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email-input"]', 'user@example.com');
await page.fill('[data-testid="password-input"]', 'wrongpassword');
await page.click('[data-testid="login-button"]');
await expect(page.locator('[data-testid="error-message"]'))
.toContainText('Invalid credentials');
await expect(page).toHaveURL('/login');
});
});Page Object Model
Encapsulate page interactions for maintainability:
// pages/LoginPage.ts
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.page.fill('[data-testid="email-input"]', email);
await this.page.fill('[data-testid="password-input"]', password);
await this.page.click('[data-testid="login-button"]');
}
async getErrorMessage() {
return this.page.locator('[data-testid="error-message"]').textContent();
}
}
// pages/DashboardPage.ts
export class DashboardPage {
constructor(private page: Page) {}
async getWelcomeMessage() {
return this.page.locator('[data-testid="welcome-message"]').textContent();
}
async navigateToProfile() {
await this.page.click('[data-testid="profile-link"]');
}
}
// tests/auth.spec.ts
test('should login successfully', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'SecurePass123!');
await expect(page).toHaveURL('/dashboard');
const welcomeMessage = await dashboardPage.getWelcomeMessage();
expect(welcomeMessage).toContain('Welcome');
});Testing Complex Workflows
Multi-Step Form
test.describe('Checkout Flow', () => {
test.beforeEach(async ({ page }) => {
// Setup: Add item to cart and navigate to checkout
await page.goto('/products/1');
await page.click('[data-testid="add-to-cart"]');
await page.goto('/checkout');
});
test('should complete full checkout', async ({ page }) => {
// Step 1: Shipping Information
await page.fill('[data-testid="shipping-name"]', 'John Doe');
await page.fill('[data-testid="shipping-address"]', '123 Main St');
await page.fill('[data-testid="shipping-city"]', 'New York');
await page.selectOption('[data-testid="shipping-state"]', 'NY');
await page.fill('[data-testid="shipping-zip"]', '10001');
await page.click('[data-testid="continue-to-payment"]');
// Step 2: Payment Information
await expect(page.locator('[data-testid="payment-section"]')).toBeVisible();
await page.fill('[data-testid="card-number"]', '4111111111111111');
await page.fill('[data-testid="card-expiry"]', '12/25');
await page.fill('[data-testid="card-cvc"]', '123');
await page.click('[data-testid="continue-to-review"]');
// Step 3: Review and Confirm
await expect(page.locator('[data-testid="order-summary"]')).toBeVisible();
await expect(page.locator('[data-testid="shipping-summary"]'))
.toContainText('123 Main St');
await page.click('[data-testid="place-order"]');
// Confirmation
await expect(page).toHaveURL(/\/orders\/\d+/);
await expect(page.locator('[data-testid="order-confirmation"]'))
.toContainText('Order Placed Successfully');
});
});Testing with Authentication State
// fixtures/auth.ts
import { test as base } from '@playwright/test';
type AuthFixtures = {
authenticatedPage: Page;
adminPage: Page;
};
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
// Login before test
await page.goto('/login');
await page.fill('[data-testid="email"]', 'user@example.com');
await page.fill('[data-testid="password"]', 'password123');
await page.click('[data-testid="login"]');
await page.waitForURL('/dashboard');
await use(page);
// Logout after test
await page.goto('/logout');
},
adminPage: async ({ page }, use) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'admin@example.com');
await page.fill('[data-testid="password"]', 'adminpass123');
await page.click('[data-testid="login"]');
await page.waitForURL('/admin');
await use(page);
},
});
// tests/dashboard.spec.ts
import { test } from '../fixtures/auth';
test('authenticated user can view dashboard', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/dashboard');
await expect(authenticatedPage.locator('h1')).toContainText('Dashboard');
});
test('admin can access admin panel', async ({ adminPage }) => {
await adminPage.goto('/admin/users');
await expect(adminPage.locator('table')).toBeVisible();
});Handling Flaky Tests
Wait Strategies
// Bad: Fixed timeout
await page.waitForTimeout(2000); // Don't do this
// Good: Wait for specific conditions
await page.waitForSelector('[data-testid="results"]');
await page.waitForLoadState('networkidle');
await expect(page.locator('[data-testid="loading"]')).toBeHidden();
// Wait for API response
await Promise.all([
page.waitForResponse(resp =>
resp.url().includes('/api/data') && resp.status() === 200
),
page.click('[data-testid="load-data"]'),
]);Retry Configuration
// playwright.config.ts
export default {
retries: process.env.CI ? 2 : 0,
timeout: 30000,
expect: {
timeout: 5000,
},
use: {
actionTimeout: 10000,
navigationTimeout: 15000,
},
};Debugging Flaky Tests
// Enable tracing for failed tests
export default {
use: {
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
};
// Run with debug mode
// npx playwright test --debugE2E Testing for Accessibility
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('homepage should be accessible', async ({ page }) => {
await page.goto('/');
const accessibilityResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(accessibilityResults.violations).toEqual([]);
});
test('form should have proper labels', async ({ page }) => {
await page.goto('/contact');
// Check that all inputs have associated labels
const inputs = await page.locator('input:not([type="hidden"])').all();
for (const input of inputs) {
const id = await input.getAttribute('id');
const label = page.locator(`label[for="${id}"]`);
await expect(label).toBeVisible();
}
});Visual Regression Testing
test('homepage visual regression', async ({ page }) => {
await page.goto('/');
// Full page screenshot
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
animations: 'disabled',
});
});
test('component visual regression', async ({ page }) => {
await page.goto('/components/button');
const button = page.locator('[data-testid="primary-button"]');
await expect(button).toHaveScreenshot('primary-button.png');
// Hover state
await button.hover();
await expect(button).toHaveScreenshot('primary-button-hover.png');
});E2E Test Organization
Directory Structure
tests/
├── e2e/
│ ├── fixtures/
│ │ ├── auth.ts
│ │ └── testData.ts
│ ├── pages/
│ │ ├── LoginPage.ts
│ │ ├── DashboardPage.ts
│ │ └── CheckoutPage.ts
│ ├── specs/
│ │ ├── auth.spec.ts
│ │ ├── checkout.spec.ts
│ │ └── dashboard.spec.ts
│ └── support/
│ ├── commands.ts
│ └── helpers.ts
├── playwright.config.ts
└── package.jsonTest Tagging
// Tag tests for selective execution
test('critical checkout flow @critical @checkout', async ({ page }) => {
// ...
});
test('edge case handling @edge', async ({ page }) => {
// ...
});
// Run only critical tests
// npx playwright test --grep @criticalCI/CD Integration
GitHub Actions Configuration
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Start application
run: npm run start:test &
env:
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
- name: Wait for application
run: npx wait-on http://localhost:3000
- name: Run E2E tests
run: npx playwright test
- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7E2E Testing for Compliance
Audit Trail Testing
test('should log all user actions for audit', async ({ page }) => {
await loginAsAdmin(page);
// Perform actions that should be logged
await page.goto('/patients/123');
await page.click('[data-testid="view-records"]');
await page.click('[data-testid="download-record"]');
// Verify audit log
await page.goto('/admin/audit-log');
const logs = await page.locator('[data-testid="audit-entry"]').all();
expect(logs.length).toBeGreaterThanOrEqual(2);
await expect(logs[0]).toContainText('Viewed patient record');
await expect(logs[1]).toContainText('Downloaded patient record');
});Session Timeout Testing
test('should enforce session timeout', async ({ page }) => {
await loginAsUser(page);
// Simulate inactivity (in real test, might use time manipulation)
await page.evaluate(() => {
// Force session expiry for testing
window.sessionStorage.setItem('sessionExpiry', Date.now() - 1000);
});
// Attempt action
await page.goto('/dashboard');
// Should redirect to login
await expect(page).toHaveURL('/login');
await expect(page.locator('[data-testid="session-expired-message"]'))
.toBeVisible();
});Best Practices Summary
Do
- Focus on critical user journeys
- Use Page Object Model
- Wait for specific conditions, not timeouts
- Run tests in isolated environments
- Use data-testid attributes for selectors
- Generate reports and screenshots
Don't
- Test every possible scenario with E2E
- Use fragile selectors (text content, CSS classes)
- Share state between tests
- Ignore flaky tests (fix or remove them)
- Run E2E tests on every commit (use CI stages)
Related Resources
Compliance
This section fulfills ISO 13485 requirements for design validation (7.3.7) and control of records (4.2.4), and ISO 27001 requirements for secure development lifecycle (A.8.25) and security testing (A.8.29).
How is this guide?
Last updated on