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
typescript// 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
typescript// 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
typescript// 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
typescript// ❌ 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
typescript// 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
typescript// 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
typescript// 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
typescript// 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
typescript// 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
typescript// 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
typescript// 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
typescript// ❌ 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
typescript// 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


