| name | e2e-testing |
| title | E2E Testing - Playwright, Cypress, and Cross-Browser Patterns |
| description | Production E2E testing patterns with Playwright and Cypress, flakiness prevention, cross-browser matrices, accessibility testing, and CI/CD integration |
| compatibility | ["agent:e2e-test-strategy","agent:contract-testing"] |
| metadata | {"domain":"testing","maturity":"production","audience":["qa-engineer","developer","test-automation-engineer"]} |
| allowed-tools | ["bash","node","python","docker"] |
E2E Testing Skill
Comprehensive patterns for building reliable, maintainable end-to-end test suites using modern testing frameworks.
Playwright E2E Setup
Installation & Configuration
npm install -D @playwright/test
npx playwright init
npx playwright install
playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
testMatch: '**/*.spec.ts',
workers: 4,
timeout: 30 * 1000,
expect: { timeout: 5 * 1000 },
reporter: [
['list'],
['html', { outputFolder: 'playwright-report' }],
['json', { outputFile: 'test-results.json' }],
],
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 Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Playwright Test Patterns
Basic Test Structure
import { test, expect } from '@playwright/test';
test('checkout flow', async ({ page }) => {
await page.goto('/');
await page.locator('[data-testid="search-input"]').fill('Blue Widget');
await page.locator('button:has-text("Search")').click();
await expect(page.locator('text=Blue Widget')).toBeVisible();
});
Smart Waits (No Magic Sleeps)
await page.click('button');
await new Promise(r => setTimeout(r, 2000));
const text = await page.textContent('h1');
await page.click('button');
await page.waitForSelector('h1:has-text("Loaded")');
const text = await page.textContent('h1');
await page.waitForFunction(() => {
const button = document.querySelector('button[data-loaded="true"]');
return button !== null;
});
Locator Selection (Robustness Order)
page.locator('[data-testid="submit-btn"]')
page.locator('button:has-text("Submit")')
page.locator('role=button[name="Submit"]')
page.locator('text="Submit"')
page.locator('//div/div/button[3]')
page.locator('div.container > div:nth-child(3) > button')
Data Fixtures & Setup
import { test as base } from '@playwright/test';
type TestFixtures = {
authenticatedPage: Page;
testUser: { email: string; password: string };
};
export const test = base.extend<TestFixtures>({
authenticatedPage: async ({ page }, use) => {
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'test-password');
await page.click('button:has-text("Login")');
await page.waitForNavigation();
await use(page);
await page.click('button:has-text("Logout")');
},
testUser: [
{ email: 'test@example.com', password: 'test-password' },
],
});
test('checkout with authenticated user', async ({ authenticatedPage, testUser }) => {
await authenticatedPage.goto('/checkout');
});
API Mocking & Stubbing
test('show error when API fails', async ({ page }) => {
await page.route('**/api/users/**', (route) => {
route.abort('failed');
});
await page.goto('/users');
await expect(page.locator('text=Error loading users')).toBeVisible();
});
test('intercept and modify API response', async ({ page }) => {
await page.route('**/api/products', (route) => {
route.continue({
response: {
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Test Product', price: 9.99 }
]),
},
});
});
await page.goto('/products');
await expect(page.locator('text=Test Product')).toBeVisible();
});
Accessibility Testing
import { test, expect } from '@playwright/test';
import { injectAxe, checkA11y } from 'axe-playwright';
test('page is accessible', async ({ page }) => {
await page.goto('/');
await injectAxe(page);
await checkA11y(page);
});
test('keyboard navigation works', async ({ page }) => {
await page.goto('/checkout');
await page.press('Tab');
await expect(page.locator('[name="address"]')).toBeFocused();
await page.press('Tab');
await expect(page.locator('[name="city"]')).toBeFocused();
await page.press('Enter');
await expect(page.locator('text=Order confirmed')).toBeVisible();
});
Performance Testing
test('page loads within Core Web Vitals targets', async ({ page }) => {
const navigationTiming = await page.evaluate(() => {
const nav = performance.getEntriesByType('navigation')[0];
return {
fcp: nav.responseEnd,
lcp: nav.domInteractive,
cls: 0,
};
});
expect(navigationTiming.lcp).toBeLessThan(2500);
});
Cypress Patterns
Installation & Configuration
npm install -D cypress
npx cypress open
cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/e2e/**/*.cy.js',
supportFile: 'cypress/support/e2e.js',
defaultCommandTimeout: 5000,
requestTimeout: 5000,
responseTimeout: 10000,
viewportWidth: 1280,
viewportHeight: 720,
screenshotOnRunFailure: true,
video: true,
},
});
Cypress Test Example
describe('Checkout flow', () => {
beforeEach(() => {
cy.visit('/');
cy.login('test@example.com', 'password');
});
it('should complete purchase', () => {
cy.get('[data-testid="search"]').type('Blue Widget');
cy.get('button:contains("Search")').click();
cy.contains('Blue Widget').should('be.visible');
cy.get('[data-testid="add-to-cart"]').click();
cy.get('[data-testid="cart-count"]').should('contain', '1');
cy.visit('/checkout');
cy.get('[name="address"]').type('123 Main St');
cy.get('[name="city"]').type('Springfield');
cy.get('[name="zip"]').type('12345');
cy.get('button:contains("Continue")').click();
cy.contains('Order Confirmed').should('be.visible');
});
});
Cypress Custom Commands
Cypress.Commands.add('login', (email, password) => {
cy.visit('/login');
cy.get('[name="email"]').type(email);
cy.get('[name="password"]').type(password);
cy.get('button:contains("Login")').click();
cy.url().should('not.include', '/login');
});
Cypress.Commands.add('logout', () => {
cy.get('[data-testid="logout-btn"]').click();
cy.url().should('include', '/login');
});
cy.login('test@example.com', 'password');
cy.logout();
Flakiness Prevention Checklist
Flakiness Prevention:
1. Eliminate Magic Sleeps:
❌ cy.wait(2000)
✅ cy.contains('Loaded').should('be.visible')
2. Use Smart Waits:
✅ cy.waitForFunction()
✅ cy.intercept() for API mocking
✅ cy.get().should('have.length', 5)
3. Isolate Tests (Fresh Fixtures):
✅ beforeEach: Setup fresh state
❌ Tests that depend on previous test order
4. Fix Brittle Locators:
❌ cy.get('div.container > div:nth-child(3) > button')
✅ cy.get('[data-testid="submit-btn"]')
5. Mock External Dependencies:
✅ cy.intercept('/api/**', { fixture: 'users.json' })
❌ Tests that hit real external APIs
6. Use Retry Logic:
✅ Playwright/Cypress retry failed tests
✅ Exponential backoff for API calls
7. Deterministic Environments:
✅ Use Docker for consistent test environment
✅ Reset database state per test
❌ Tests that depend on current date/time
Cross-Browser CI/CD Matrix
GitHub Actions Workflow
name: E2E Tests
on: [pull_request, push]
jobs:
smoke-tests:
runs-on: ubuntu-latest
strategy:
matrix:
browser: [chromium]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- run: npm ci
- run: npx playwright install
- run: npm run test:e2e -- --project=${{ matrix.browser }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-${{ matrix.browser }}
path: playwright-report/
full-matrix:
runs-on: ${{ matrix.os }}
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- run: npm ci
- run: npx playwright install
- run: npx playwright test --project=${{ matrix.browser }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-${{ matrix.os }}-${{ matrix.browser }}
path: playwright-report/
Test Data Management
Avoid Test Database Dependencies
test('find user by email', async ({ page }) => {
await page.goto('/users');
await page.fill('[name="search"]', 'test@example.com');
await expect(page.locator('text=test@example.com')).toBeVisible();
});
test('find user by email', async ({ page, request }) => {
const user = await request.post('/api/users', {
data: { email: 'test@example.com', name: 'Test User' }
});
await page.goto('/users');
await page.fill('[name="search"]', 'test@example.com');
await expect(page.locator('text=test@example.com')).toBeVisible();
await request.delete(`/api/users/${user.id}`);
});
Performance Baselines
Track Core Web Vitals over time:
test('measure Core Web Vitals', async ({ page }) => {
const vitals = await page.evaluate(() => {
const nav = performance.getEntriesByType('navigation')[0];
const paint = performance.getEntriesByType('paint');
const largest = performance.getEntriesByType('largest-contentful-paint').pop();
return {
fcp: paint.find(p => p.name === 'first-contentful-paint')?.startTime || 0,
lcp: largest?.startTime || 0,
fid: 0,
cls: 0,
};
});
console.log('Core Web Vitals:', vitals);
expect(vitals.fcp).toBeLessThan(1800);
expect(vitals.lcp).toBeLessThan(2500);
});
Maintenance
Test Suite Health Monitoring
- Pass Rate: Target 99%+ (investigate any below 95%)
- Flaky Tests: Track and remediate (dedicate 10% sprint capacity)
- Execution Time: Target < 5 minutes for smoke tests, < 30 minutes for full suite
- Maintenance Cost: Tests should cost less than 50% of feature development time
Quarterly Audit Checklist