E2E Testing Patterns
Build reliable, fast, and maintainable end-to-end test suites that provide confidence to ship code quickly and catch regressions before users do.
When to Use This Skill
- Implementing end-to-end test automation
- Debugging flaky or unreliable tests
- Testing critical user workflows
- Setting up CI/CD test pipelines
- Testing across multiple browsers
- Validating accessibility requirements
- Testing responsive designs
- Establishing E2E testing standards
Core Concepts
1. E2E Testing Fundamentals
What to Test with E2E:
- Critical user journeys (login, checkout, signup)
- Complex interactions (drag-and-drop, multi-step forms)
- Cross-browser compatibility
- Real API integration
- Authentication flows
What NOT to Test with E2E:
- Unit-level logic (use unit tests)
- API contracts (use integration tests)
- Edge cases (too slow)
- Internal implementation details
2. Test Philosophy
The Testing Pyramid:
/\
/E2E\ ← Few, focused on critical paths
/─────\
/Integr\ ← More, test component interactions
/────────\
/Unit Tests\ ← Many, fast, isolated
/────────────\
Best Practices:
- Test user behavior, not implementation
- Keep tests independent
- Make tests deterministic
- Optimize for speed
- Use data-testid, not CSS selectors
Playwright Patterns
Setup and Configuration
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 30000,
expect: {
timeout: 5000,
},
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [['html'], ['junit', { outputFile: 'results.xml' }]],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile', use: { ...devices['iPhone 13'] } },
],
})
Pattern 1: Page Object Model
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test'
export class LoginPage {
readonly page: Page
readonly emailInput: Locator
readonly passwordInput: Locator
readonly loginButton: Locator
readonly errorMessage: Locator
constructor(page: Page) {
this.page = page
this.emailInput = page.getByLabel('Email')
this.passwordInput = page.getByLabel('Password')
this.loginButton = page.getByRole('button', { name: 'Login' })
this.errorMessage = page.getByRole('alert')
}
async goto() {
await this.page.goto('/login')
}
async login(email: string, password: string) {
await this.emailInput.fill(email)
await this.passwordInput.fill(password)
await this.loginButton.click()
}
async getErrorMessage(): Promise<string> {
return (await this.errorMessage.textContent()) ?? ''
}
}
// Test using Page Object
import { test, expect } from '@playwright/test'
import { LoginPage } from './pages/LoginPage'
test('successful login', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('user@example.com', 'password123')
await expect(page).toHaveURL('/dashboard')
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
})
test('failed login shows error', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('invalid@example.com', 'wrong')
const error = await loginPage.getErrorMessage()
expect(error).toContain('Invalid credentials')
})
Pattern 2: Fixtures for Test Data
// fixtures/test-data.ts
import { test as base } from '@playwright/test'
type TestData = {
testUser: {
email: string
password: string
name: string
}
adminUser: {
email: string
password: string
}
}
export const test = base.extend<TestData>({
testUser: async ({}, use) => {
const user = {
email: `test-${Date.now()}@example.com`,
password: 'Test123!@#',
name: 'Test User',
}
// Setup: Create user in database
await createTestUser(user)
await use(user)
// Teardown: Clean up user
await deleteTestUser(user.email)
},
adminUser: async ({}, use) => {
await use({
email: 'admin@example.com',
password: process.env.ADMIN_PASSWORD!,
})
},
})
// Usage in tests
import { test } from './fixtures/test-data'
test('user can update profile', async ({ page, testUser }) => {
await page.goto('/login')
await page.getByLabel('Email').fill(testUser.email)
await page.getByLabel('Password').fill(testUser.password)
await page.getByRole('button', { name: 'Login' }).click()
await page.goto('/profile')
await page.getByLabel('Name').fill('Updated Name')
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByText('Profile updated')).toBeVisible()
})
Pattern 3: Waiting Strategies
// ❌ Bad: Fixed timeouts
await page.waitForTimeout(3000) // Flaky!
// ✅ Good: Wait for specific conditions
await page.waitForLoadState('networkidle')
await page.waitForURL('/dashboard')
await page.waitForSelector('[data-testid="user-profile"]')
// ✅ Better: Auto-waiting with assertions
await expect(page.getByText('Welcome')).toBeVisible()
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled()
// Wait for API response
const responsePromise = page.waitForResponse(
(response) => response.url().includes('/api/users') && response.status() === 200
)
await page.getByRole('button', { name: 'Load Users' }).click()
const response = await responsePromise
const data = await response.json()
expect(data.users).toHaveLength(10)
// Wait for multiple conditions
await Promise.all([
page.waitForURL('/success'),
page.waitForLoadState('networkidle'),
expect(page.getByText('Payment successful')).toBeVisible(),
])
Pattern 4: Network Mocking and Interception
// Mock API responses
test('displays error when API fails', async ({ page }) => {
await page.route('**/api/users', (route) => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal Server Error' }),
})
})
await page.goto('/users')
await expect(page.getByText('Failed to load users')).toBeVisible()
})
// Intercept and modify requests
test('can modify API request', async ({ page }) => {
await page.route('**/api/users', async (route) => {
const request = route.request()
const postData = JSON.parse(request.postData() || '{}')
// Modify request
postData.role = 'admin'
await route.continue({
postData: JSON.stringify(postData),
})
})
// Test continues...
})
// Mock third-party services
test('payment flow with mocked Stripe', async ({ page }) => {
await page.route('**/api/stripe/**', (route) => {
route.fulfill({
status: 200,
body: JSON.stringify({
id: 'mock_payment_id',
status: 'succeeded',
}),
})
})
// Test payment flow with mocked response
})
Cypress Patterns
Setup and Configuration
// cypress.config.ts
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
viewportWidth: 1280,
viewportHeight: 720,
video: false,
screenshotOnRunFailure: true,
defaultCommandTimeout: 10000,
requestTimeout: 10000,
setupNodeEvents(on, config) {
// Implement node event listeners
},
},
})
Pattern 1: Custom Commands
// cypress/support/commands.ts
declare global {
namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>
createUser(userData: UserData): Chainable<User>
dataCy(value: string): Chainable<JQuery<HTMLElement>>
}
}
}
Cypress.Commands.add('login', (email: string, password: string) => {
cy.visit('/login')
cy.get('[data-testid="email"]').type(email)
cy.get('[data-testid="password"]').type(password)
cy.get('[data-testid="login-button"]').click()
cy.url().should('include', '/dashboard')
})
Cypress.Commands.add('createUser', (userData: UserData) => {
return cy.request('POST', '/api/users', userData).its('body')
})
Cypress.Commands.add('dataCy', (value: string) => {
return cy.get(`[data-cy="${value}"]`)
})
// Usage
cy.login('user@example.com', 'password')
cy.dataCy('submit-button').click()
Pattern 2: Cypress Intercept
// Mock API calls
cy.intercept('GET', '/api/users', {
statusCode: 200,
body: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
],
}).as('getUsers')
cy.visit('/users')
cy.wait('@getUsers')
cy.get('[data-testid="user-list"]').children().should('have.length', 2)
// Modify responses
cy.intercept('GET', '/api/users', (req) => {
req.reply((res) => {
// Modify response
res.body.users = res.body.users.slice(0, 5)
res.send()
})
})
// Simulate slow network
cy.intercept('GET', '/api/data', (req) => {
req.reply((res) => {
res.delay(3000) // 3 second delay
res.send()
})
})
Advanced Patterns
Pattern 1: Visual Regression Testing
// With Playwright
import { test, expect } from '@playwright/test'
test('homepage looks correct', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
maxDiffPixels: 100,
})
})
test('button in all states', async ({ page }) => {
await page.goto('/components')
const button = page.getByRole('button', { name: 'Submit' })
// Default state
await expect(button).toHaveScreenshot('button-default.png')
// Hover state
await button.hover()
await expect(button).toHaveScreenshot('button-hover.png')
// Disabled state
await button.evaluate((el) => el.setAttribute('disabled', 'true'))
await expect(button).toHaveScreenshot('button-disabled.png')
})
Pattern 2: Parallel Testing with Sharding
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'shard-1',
use: { ...devices['Desktop Chrome'] },
grepInvert: /@slow/,
shard: { current: 1, total: 4 },
},
{
name: 'shard-2',
use: { ...devices['Desktop Chrome'] },
shard: { current: 2, total: 4 },
},
// ... more shards
],
})
// Run in CI
// npx playwright test --shard=1/4
// npx playwright test --shard=2/4
Pattern 3: Accessibility Testing
// Install: npm install @axe-core/playwright
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
test('page should not have accessibility violations', async ({ page }) => {
await page.goto('/')
const accessibilityScanResults = await new AxeBuilder({ page }).exclude('#third-party-widget').analyze()
expect(accessibilityScanResults.violations).toEqual([])
})
test('form is accessible', async ({ page }) => {
await page.goto('/signup')
const results = await new AxeBuilder({ page }).include('form').analyze()
expect(results.violations).toEqual([])
})
Best Practices
- Use Data Attributes:
data-testidordata-cyfor stable selectors - Avoid Brittle Selectors: Don't rely on CSS classes or DOM structure
- Test User Behavior: Click, type, see - not implementation details
- Keep Tests Independent: Each test should run in isolation
- Clean Up Test Data: Create and destroy test data in each test
- Use Page Objects: Encapsulate page logic
- Meaningful Assertions: Check actual user-visible behavior
- Optimize for Speed: Mock when possible, parallel execution
// ❌ Bad selectors
cy.get('.btn.btn-primary.submit-button').click()
cy.get('div > form > div:nth-child(2) > input').type('text')
// ✅ Good selectors
cy.getByRole('button', { name: 'Submit' }).click()
cy.getByLabel('Email address').type('user@example.com')
cy.get('[data-testid="email-input"]').type('user@example.com')
Common Pitfalls
- Flaky Tests: Use proper waits, not fixed timeouts
- Slow Tests: Mock external APIs, use parallel execution
- Over-Testing: Don't test every edge case with E2E
- Coupled Tests: Tests should not depend on each other
- Poor Selectors: Avoid CSS classes and nth-child
- No Cleanup: Clean up test data after each test
- Testing Implementation: Test user behavior, not internals
Debugging Failing Tests
// Playwright debugging
// 1. Run in headed mode
npx playwright test --headed
// 2. Run in debug mode
npx playwright test --debug
// 3. Use trace viewer
await page.screenshot({ path: 'screenshot.png' });
await page.video()?.saveAs('video.webm');
// 4. Add test.step for better reporting
test('checkout flow', async ({ page }) => {
await test.step('Add item to cart', async () => {
await page.goto('/products');
await page.getByRole('button', { name: 'Add to Cart' }).click();
});
await test.step('Proceed to checkout', async () => {
await page.goto('/cart');
await page.getByRole('button', { name: 'Checkout' }).click();
});
});
// 5. Inspect page state
await page.pause(); // Pauses execution, opens inspector
Resources
- references/playwright-best-practices.md: Playwright-specific patterns
- references/cypress-best-practices.md: Cypress-specific patterns
- references/flaky-test-debugging.md: Debugging unreliable tests
- assets/e2e-testing-checklist.md: What to test with E2E
- assets/selector-strategies.md: Finding reliable selectors
- scripts/test-analyzer.ts: Analyze test flakiness and duration