| name | e2e-test |
| description | End-to-end testing with Playwright/Cypress for full user flows. Outputs test scenarios, page objects, CI integration, and flake reduction strategies. |
| argument-hint | ["user flows","browser targets","test environment"] |
| allowed-tools | Read, Write, Bash |
End-to-End Testing
Design E2E tests that verify complete user journeys through the UI. Not fragile Selenium scripts — modern Playwright/Cypress with auto-waiting, retry logic, and stable selectors.
Process
- Identify critical flows. Login, checkout, signup, admin tasks.
- Choose framework. Playwright (multi-browser, parallel), Cypress (developer UX).
- Design page objects. Reusable components, stable selectors.
- Handle async. Auto-waiting, explicit waits, network idle.
- Manage test data. Seed DB, API fixtures, cleanup.
- Run in CI. Headless mode, screenshots on failure, video recording.
- Reduce flakes. Retries, deterministic waits, stable selectors.
Output Format
E2E Tests: [Application Name]
Framework: Playwright
Browsers: Chromium, Firefox, WebKit
Flows: 12 critical paths
Execution: Parallel (4 workers)
Duration: 5 minutes (full suite)
Flake Rate: < 2%
Framework Comparison
| Feature | Playwright | Cypress |
|---|
| Multi-browser | ✅ Chrome, Firefox, Safari | ⚠️ Chrome, Firefox, Edge (beta) |
| Speed | Fast (parallel) | Medium |
| Auto-waiting | ✅ | ✅ |
| Network mocking | ✅ | ✅ |
| Screenshots/Video | ✅ | ✅ |
| Real mobile | ✅ Device emulation | ⚠️ Viewport only |
| Learning curve | Medium | Easy |
Recommendation: Playwright for flexibility, Cypress for simplicity
Playwright Tests
Basic Test
import { test, expect } from '@playwright/test';
test('user can login', async ({ page }) => {
await page.goto('https://app.example.com/login');
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/.*dashboard/);
await expect(page.locator('h1')).toContainText('Welcome');
});
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('https://app.example.com/login');
await page.fill('input[name="email"]', 'wrong@example.com');
await page.fill('input[name="password"]', 'wrong');
await page.();
(page.()).();
(page.()).();
});
Page Object Pattern
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.page.fill('input[name="email"]', email);
await this.page.fill('input[name="password"]', password);
await this.page.click('button[type="submit"]');
}
async getErrorMessage() {
return await this.page.locator('.error').textContent();
}
}
test('login with page object', async ({ page }) => {
loginPage = (page);
loginPage.();
loginPage.(, );
(page).();
});
Complete E2E Flow
test('complete checkout flow', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'buyer@example.com');
await page.fill('[data-testid="password"]', 'password123');
await page.click('[data-testid="login-button"]');
await page.goto('/products');
await page.click('text=Product A');
await page.click('[data-testid="add-to-cart"]');
await expect(page.locator('.cart-count')).toHaveText('1');
await page.click('[data-testid="cart-icon"]');
await page.click('text=Checkout');
await page.fill('[name="address"]', '123 Main St');
await page.fill(, );
page.(, );
page.(, );
page.();
page.(, );
page.(, );
page.(, );
page.();
(page.()).();
orderNumber = page.().();
(orderNumber).();
});
Stable Selectors
Bad (Fragile)
await page.click('.btn-primary.mt-4.px-3');
await page.click('div > div > button:nth-child(2)');
await page.click('text=Submit');
Good (Stable)
await page.click('[data-testid="submit-button"]');
await page.click('button[aria-label="Submit form"]');
await page.click('button:has-text("Submit")');
Add test IDs to components:
<button data-testid="submit-button" className="btn-primary">
Submit
</button>
Auto-Waiting (No Sleep!)
await page.click('button');
await page.waitForTimeout(2000);
await expect(page.locator('.result')).toBeVisible();
await page.click('button');
await expect(page.locator('.result')).toBeVisible();
await page.click('button');
await page.waitForResponse(resp => resp.url().includes('/api/submit'));
await expect(page.locator('.result')).toBeVisible();
Network Interception
Mock API Responses
test('shows products from mocked API', async ({ page }) => {
await page.route('**/api/products', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
products: [
{ id: 1, name: 'Test Product', price: 10.00 }
]
})
});
});
await page.goto('/products');
await expect(page.locator('.product-name')).toHaveText('Test Product');
});
Wait for API Call
test('waits for data to load', async ({ page }) => {
await page.goto('/dashboard');
const responsePromise = page.waitForResponse(
resp => resp.url().includes('/api/stats') && resp.status() === 200
);
const response = await responsePromise;
const data = await response.json();
expect(data.total_users).toBeGreaterThan(0);
});
Authentication State
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
await page.context().storageState({ path: 'auth.json' });
});
export default {
use: {
storageState: 'auth.json'
}
};
test('access dashboard as logged in user', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.locator()).();
});
Test Data Management
API-Based Setup
test.beforeEach(async ({ request }) => {
await request.post('/api/test/users', {
data: {
email: 'test@example.com',
password: 'password123'
}
});
await request.post('/api/test/products', {
data: {
products: [
{ name: 'Product A', price: 10.00 },
{ name: 'Product B', price: 20.00 }
]
}
});
});
test.afterEach(async ({ request }) => {
await request.delete('/api/test/reset');
});
Database Seeding
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
test.beforeAll(async () => {
await execAsync('npm run db:reset:test');
await execAsync('npm run db:seed:test');
});
Screenshot & Video on Failure
export default {
use: {
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'retain-on-failure'
}
};
test('important flow', async ({ page }) => {
await page.goto('/checkout');
await page.screenshot({ path: 'checkout.png', fullPage: true });
});
Parallel Execution
export default {
workers: process.env.CI ? 4 : 2,
fullyParallel: true
};
npx playwright test --workers=4
Flake Reduction
Retry Failed Tests
export default {
retries: process.env.CI ? 2 : 0
};
Test Isolation
test.describe.configure({ mode: 'parallel' });
test.beforeEach(async ({ page, context }) => {
await context.clearCookies();
await context.clearPermissions();
});
Deterministic Waits
await page.click('button');
const text = await page.locator('.result').textContent();
await page.click('button');
await page.waitForLoadState('networkidle');
const text = await page.locator('.result').textContent();
CI Integration
name: E2E Tests
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run E2E tests
run: npx playwright test
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
Visual Regression Testing
test('homepage looks correct', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixels: 100
});
});
npx playwright test --update-snapshots
Mobile Testing
import { devices } from '@playwright/test';
export default {
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'iphone', use: { ...devices['iPhone 13'] } },
{ name: 'pixel', use: { ...devices['Pixel 5'] } }
]
};
test('mobile menu works', async ({ page, isMobile }) => {
test.skip(!isMobile, 'Mobile only');
await page.goto('/');
await page.click('[data-testid="mobile-menu-button"]');
await expect(page.locator('nav')).toBeVisible();
});
Rules
- E2E tests verify critical user flows only — 10-20 tests, not 1000.
- Use stable selectors (data-testid) not CSS classes or DOM structure.
- No explicit waits (sleep) — use auto-waiting and network idle.
- Run in CI on every PR — catch regressions before production.
- Screenshot/video on failure for debugging — essential for CI failures.
- Parallel execution reduces runtime 4x — 20min → 5min.
- Retry flaky tests 2x in CI — reduces false negatives.
- Page object pattern for reusability — avoid duplicating selectors.
- API-based test data setup faster than UI — seed via /api/test endpoints.
- Flake rate must be < 5% — fix or disable flaky tests, don't ignore.