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