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
- 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
- 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-testid or data-cy for 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