E2E Testing Patterns
Build reliable, fast, and maintainable end-to-end test suites with Playwright and Cypress.
What to Test with E2E
Good for:
-
Critical user journeys (login, checkout, signup)
-
Complex interactions (drag-and-drop, multi-step forms)
-
Cross-browser compatibility
-
Real API integration
Not for:
-
Unit-level logic (use unit tests)
-
API contracts (use integration tests)
-
Edge cases (too slow)
Playwright Configuration
// playwright.config.ts export default defineConfig({ testDir: './e2e', timeout: 30000, fullyParallel: true, retries: process.env.CI ? 2 : 0, use: { baseURL: 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, { name: 'mobile', use: { ...devices['iPhone 13'] } }, ], });
Page Object Model
export class LoginPage { readonly page: Page; readonly emailInput: Locator; readonly loginButton: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.loginButton = page.getByRole('button', { name: 'Login' });
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.page.getByLabel('Password').fill(password);
await this.loginButton.click();
}
}
Waiting Strategies
// Bad: Fixed timeouts await page.waitForTimeout(3000); // Flaky!
// Good: Wait for conditions await expect(page.getByText('Welcome')).toBeVisible(); await page.waitForURL('/dashboard');
// Wait for API response const responsePromise = page.waitForResponse( r => r.url().includes('/api/users') && r.status() === 200 ); await page.click('button'); await responsePromise;
Network Mocking
test('displays error when API fails', async ({ page }) => { await page.route('**/api/users', route => { route.fulfill({ status: 500, body: JSON.stringify({ error: 'Server Error' }), }); });
await page.goto('/users');
await expect(page.getByText('Failed to load')).toBeVisible();
});
Visual Regression
test('homepage looks correct', async ({ page }) => { await page.goto('/'); await expect(page).toHaveScreenshot('homepage.png', { fullPage: true, maxDiffPixels: 100, }); });
Accessibility Testing
import AxeBuilder from '@axe-core/playwright';
test('no accessibility violations', async ({ page }) => { await page.goto('/'); const results = await new AxeBuilder({ page }).analyze(); expect(results.violations).toEqual([]); });
Best Practices
-
Use Data Attributes: data-testid for stable selectors
-
Test User Behavior: Click, type, see - not implementation
-
Keep Tests Independent: Each test runs in isolation
-
Clean Up Test Data: Create and destroy per test
-
Use Page Objects: Encapsulate page logic
-
Optimize for Speed: Mock when possible, parallel execution
Bad vs Good Selectors
// Bad cy.get('.btn.btn-primary.submit-button').click(); cy.get('div > form > div:nth-child(2) > input').type('text');
// Good cy.getByRole('button', { name: 'Submit' }).click(); cy.get('[data-testid="email-input"]').type('user@example.com');
Debugging
Headed mode
npx playwright test --headed
Debug mode (step through)
npx playwright test --debug
Trace viewer
npx playwright show-trace trace.zip