Source: wshobson/agents Original Plugin: developer-essentials
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