Playwright vs Cypress: E2E Testing Strategies for Modern Web Apps
Build reliable, maintainable E2E suites with Playwright and Cypress: framework selection, flaky-test prevention, CI/CD integration, and optimization.
E2E suites fail for the same handful of reasons in every framework: state shared between tests, selectors tied to markup, and static waits standing in for a real condition. The framework decides how pleasant debugging is. The architecture decides whether the suite still runs six months and one redesign later.
For a new suite, start with Playwright. Free native parallelism, WebKit and Firefox coverage, and the trace viewer remove three recurring sources of pain without a paid tier. Cypress stays a reasonable choice for teams that already work inside its interactive runner. Everything after that decision is architecture: page objects, stable data-testid selectors, API-created test data, and a pyramid that keeps edge cases out of the browser.
Framework Selection: Playwright vs Cypress
Architectural Differences
Playwright is the safer default for a new suite. Cypress earns the slot when a team values its interactive runner more than free shards. These are the capabilities that drive the call:
Working Examples
Here’s a basic Playwright test demonstrating auto-waiting:
import { test, expect } from '@playwright/test';
test('user can complete purchase flow', async ({ page }) => {
await page.goto('/products');
// Auto-waits for element to be actionable
await page.getByTestId('product-add-to-cart').click();
await page.getByTestId('checkout-button').click();
// Fill checkout form
await page.getByTestId('shipping-name').fill('John Doe');
await page.getByTestId('shipping-address').fill('123 Main St');
await page.getByTestId('payment-card').fill('4242424242424242');
await page.getByTestId('place-order').click();
// Web-first assertion auto-retries
await expect(page.getByTestId('order-confirmation')).toBeVisible();
});
The same test in Cypress:
describe('Purchase Flow', () => {
it('allows user to complete purchase', () => {
cy.visit('/products');
cy.get('[data-testid="product-add-to-cart"]').click();
cy.get('[data-testid="checkout-button"]').click();
cy.get('[data-testid="shipping-name"]').type('John Doe');
cy.get('[data-testid="shipping-address"]').type('123 Main St');
cy.get('[data-testid="payment-card"]').type('4242424242424242');
cy.get('[data-testid="place-order"]').click();
cy.get('[data-testid="order-confirmation"]').should('be.visible');
});
});
Both accomplish the same goal. Playwright’s advantage shows in parallel execution: 8 shards run simultaneously without additional cost. Cypress needs a Cypress Cloud subscription for the same capability.
Test Architecture with Page Object Model
Page objects decouple tests from UI structure. When a button moves or a class name changes, you update one file instead of dozens of tests.
Modern Page Object Implementation
// page-objects/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByTestId('login-email-input');
this.passwordInput = page.getByTestId('login-password-input');
this.submitButton = page.getByTestId('login-submit-button');
this.errorMessage = page.getByTestId('login-error-message');
}
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.submitButton.click();
}
async expectLoginSuccess() {
await expect(this.page).toHaveURL(/\/dashboard/);
}
async expectLoginError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}
Usage in tests:
test('valid credentials allow login', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'password123');
await loginPage.expectLoginSuccess();
});
test('invalid credentials show error', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'wrongpassword');
await loginPage.expectLoginError('Invalid credentials');
});
Selector Stability
Use data-testid attributes for elements you’ll test. A reliable naming convention: {scope}-{element}-{type}.
<!-- Good: Stable, descriptive test IDs -->
<button data-testid="product-list-add-to-cart-button">Add to Cart</button>
<input data-testid="checkout-shipping-name-input" />
<div data-testid="order-confirmation-message">Order placed successfully</div>
<!-- Avoid: CSS classes change during refactors -->
<button class="btn btn-primary add-cart">Add to Cart</button>
When semantic HTML exists, prefer role-based locators:
// Better: Uses accessible role
await page.getByRole('button', { name: 'Add to Cart' }).click();
// Good: Explicit test ID
await page.getByTestId('add-to-cart-button').click();
// Fragile: Implementation-dependent
await page.locator('.product-card > .actions > button:nth-child(1)').click();
API Mocking Strategies
Mocking external APIs provides test isolation and reliability. The approach depends on your rendering strategy.
Playwright Native Mocking
For client-side apps, page.route() handles most cases:
test('shows error when API fails', async ({ page }) => {
// Intercept API call and return error
await page.route('**/api/products', route => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal Server Error' })
});
});
await page.goto('/products');
await expect(page.getByTestId('error-message'))
.toContainText('Failed to load products');
});
MSW for Comprehensive Mocking
Mock Service Worker provides a more robust API for complex scenarios:
// mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/products', () => {
return HttpResponse.json([
{ id: 1, name: 'Product 1', price: 29.99 },
{ id: 2, name: 'Product 2', price: 39.99 }
]);
}),
http.post('/api/orders', async () => {
return HttpResponse.json(
{ orderId: '12345', status: 'confirmed' },
{ status: 201 }
);
})
];
Integration with Playwright:
// tests/msw.setup.ts
test.beforeEach(async ({ page }) => {
// addInitScript runs inside the page, where module imports are unavailable.
// Bundle setupWorker(...handlers).start() to a file first, then inject it.
await page.addInitScript({ path: './tests/msw-init.bundle.js' });
});
Gotcha: MSW’s service worker makes network requests invisible to page.route(). Use one approach consistently or integrate explicitly with @msw/playwright.
Flaky Test Prevention
Flaky tests erode confidence faster than no tests. Here’s what causes them and how to fix them:
Anti-patterns to Avoid
// BAD: Static waits introduce flakiness
await page.click('#submit');
await page.waitForTimeout(3000); // Might be too short or too long
await page.click('#next-step');
// Auto-waiting handles timing
await page.getByTestId('submit-button').click();
await expect(page.getByTestId('next-step-button')).toBeVisible();
// BAD: Unstable selectors break with UI changes
await page.click('div.container > ul > li:nth-child(3) > button');
// Stable selectors survive refactoring
await page.getByTestId('user-list-item-delete-button').click();
Retry Configuration
Retries are diagnostic tools, not solutions. Use them in CI to handle intermittent infrastructure issues:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
retries: process.env.CI ? 2 : 0, // Retry only in CI
use: {
actionTimeout: 10000,
navigationTimeout: 30000,
trace: 'retain-on-failure', // Critical for debugging
screenshot: 'only-on-failure',
video: 'retain-on-failure'
}
});
CI/CD Integration with Sharding
Parallel execution turns a long suite into a short feedback loop, and GitHub Actions supports Playwright’s shard flag directly:
# .github/workflows/e2e-tests.yml
name: E2E Tests
on: [push, pull_request]
jobs:
playwright-tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: blob-report
- uses: actions/upload-artifact@v4
if: always()
with:
name: blob-report-${{ matrix.shardIndex }}
path: blob-report
retention-days: 1
merge-reports:
needs: playwright-tests
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/download-artifact@v4
with:
pattern: blob-report-*
path: all-blob-reports
merge-multiple: true
- run: npx playwright merge-reports --reporter html ./all-blob-reports
- uses: actions/upload-artifact@v4
with:
name: html-report
path: playwright-report
retention-days: 14
Performance impact: the arithmetic is simple. A 35-minute suite split across 8 shards finishes in roughly 5 minutes of wall-clock time, while total runner-minutes rise by about 14% (8 shards at 5 minutes each against 35 sequential). Teams feel the wall clock far more than the runner-minutes, so the trade usually pays for itself.
Test Data Management
Clean test data practices prevent interference between tests and improve reliability.
Factory Pattern
// test-data/factories.ts
import { Page } from '@playwright/test';
export class UserFactory {
static async create(page: Page, overrides?: Partial<User>) {
const userData = {
email: `test-${Date.now()}@example.com`,
name: 'Test User',
role: 'member',
...overrides
};
// Create via API instead of clicking through the signup form
const response = await page.request.post('/api/users', {
data: userData
});
return response.json();
}
static async cleanup(page: Page, userId: string) {
await page.request.delete(`/api/users/${userId}`);
}
}
// Usage in tests
test('user can update profile', async ({ page }) => {
const user = await UserFactory.create(page);
await page.goto(`/profile/${user.id}`);
await page.getByTestId('profile-name').fill('Updated Name');
await page.getByTestId('profile-save').click();
await expect(page.getByTestId('profile-name')).toHaveValue('Updated Name');
await UserFactory.cleanup(page, user.id);
});
Playwright Fixtures
Fixtures handle setup and teardown automatically:
// fixtures/index.ts
import { test as base } from '@playwright/test';
export const test = base.extend({
authenticatedUser: async ({ page }, use) => {
const user = await UserFactory.create(page, { role: 'user' });
await loginAs(page, user);
await use(user);
await UserFactory.cleanup(page, user.id);
},
adminUser: async ({ page }, use) => {
const admin = await UserFactory.create(page, { role: 'admin' });
await loginAs(page, admin);
await use(admin);
await UserFactory.cleanup(page, admin.id);
}
});
// Clean test code
test('user can add item to cart', async ({ authenticatedUser, page }) => {
await page.goto('/products');
await page.getByTestId('product-add-to-cart').first().click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
});
Visual Regression Testing
Visual regressions slip past functional tests. Automated screenshot comparison catches them.
Playwright Built-in Visual Testing
test('dashboard layout remains consistent', async ({ page }) => {
await page.goto('/dashboard');
// Wait for dynamic content to load
await page.waitForLoadState('networkidle');
// Mask dynamic elements
await expect(page).toHaveScreenshot('dashboard.png', {
mask: [
page.getByTestId('user-greeting'), // Contains timestamp
page.getByTestId('notification-badge') // Dynamic count
],
maxDiffPixels: 100
});
});
Gotcha: Screenshots are OS-dependent. A screenshot taken on macOS won’t match Linux. Run visual tests in Docker containers for consistency:
# Dockerfile.test
FROM mcr.microsoft.com/playwright:v1.47.0-jammy
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npx", "playwright", "test"]
SaaS Alternatives
For teams needing cross-platform consistency without Docker complexity:
- Percy: AI-powered diff detection and cross-browser rendering, priced by team size
- Chromatic: Storybook integration and a visual approval workflow, priced by snapshot volume
- Lost Pixel (open-source): Self-hosted alternative to Percy
Trade-off: SaaS tools cost money but eliminate infrastructure management. Built-in solutions are free but require containerization discipline.
Mobile Testing
More than half of web traffic comes from mobile devices. Testing desktop-only misses critical issues.
Device Emulation
import { test, devices } from '@playwright/test';
// Use pre-configured device
test.use(devices['iPhone 14 Pro']);
test('mobile navigation works', async ({ page }) => {
await page.goto('/');
// Touch events automatically enabled
await page.getByTestId('mobile-menu-button').tap();
await expect(page.getByTestId('mobile-nav')).toBeVisible();
});
// Test multiple devices
const mobileDevices = ['iPhone 14 Pro', 'Pixel 5', 'Galaxy S24'];
for (const deviceName of mobileDevices) {
test.describe(deviceName, () => {
test.use(devices[deviceName]);
test('checkout flow completes', async ({ page }) => {
await page.goto('/checkout');
// Test adapts to viewport
});
});
}
Geolocation Testing
test.use({
geolocation: { longitude: -122.4194, latitude: 37.7749 },
permissions: ['geolocation']
});
test('shows nearby stores based on location', async ({ page }) => {
await page.goto('/stores');
await expect(page.getByTestId('store-location'))
.toContainText('San Francisco');
// Change location mid-test
await page.context().setGeolocation({
longitude: -73.935242,
latitude: 40.730610
});
await page.reload();
await expect(page.getByTestId('store-location'))
.toContainText('New York');
});
Accessibility Testing
Automated checks catch the mechanical WCAG violations: missing labels, insufficient contrast, misused ARIA. Judgement calls still need a person looking at the screen. Wire the automated part into every test run so manual review starts from a shorter list.
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('homepage meets WCAG 2.1 AA standards', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.exclude('#third-party-widget') // External widgets you don't control
.analyze();
expect(results.violations).toEqual([]);
});
test('keyboard navigation works throughout app', async ({ page }) => {
await page.goto('/');
// Tab through interactive elements
await page.keyboard.press('Tab');
await expect(page.getByTestId('search-input')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByTestId('nav-link-about')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByTestId('nav-link-products')).toBeFocused();
});
For gradual adoption, log violations without failing tests initially:
const results = await new AxeBuilder({ page }).analyze();
if (results.violations.length > 0) {
console.warn(`[WARN] ${results.violations.length} accessibility violations found:`);
results.violations.forEach(violation => {
console.warn(` ${violation.id}: ${violation.description}`);
console.warn(` Impact: ${violation.impact}`);
console.warn(` Affected elements: ${violation.nodes.length}`);
});
}
Component vs E2E Testing
Not everything needs E2E testing. The test pyramid still applies.
Practical Distribution
- 70% Unit/Component tests: Business logic, edge cases, calculations
- 20% Integration tests: API + component interaction, multi-step workflows
- 10% E2E tests: Critical user journeys (login, purchase, signup)
Example of testing at the right level:
// BAD: Don't test edge cases at E2E level
test('coupon code validation: expired codes', async ({ page }) => {
await page.goto('/');
await page.getByTestId('product-add').click();
await page.getByTestId('checkout').click();
await page.getByTestId('coupon-input').fill('EXPIRED2020');
await page.getByTestId('coupon-apply').click();
await expect(page.getByTestId('error')).toContainText('expired');
});
// Test at component level instead
// tests/components/CouponValidator.test.ts
test('rejects expired coupon codes', () => {
const validator = new CouponValidator();
expect(validator.validate('EXPIRED2020')).toEqual({
valid: false,
error: 'Coupon has expired'
});
});
// E2E tests focus on happy paths
test('user completes purchase with valid coupon', async ({ page }) => {
await page.goto('/');
await page.getByTestId('product-add').click();
await page.getByTestId('checkout').click();
await page.getByTestId('coupon-input').fill('SAVE20');
await page.getByTestId('coupon-apply').click();
await expect(page.getByTestId('discount')).toContainText('$20.00');
await page.getByTestId('complete-order').click();
await expect(page.getByTestId('confirmation')).toBeVisible();
});
Common Pitfalls and Solutions
Pitfall 1: Over-Reliance on E2E Tests
Symptom: Test suite takes 30+ minutes, catches mostly unit-level bugs.
Solution: Move edge cases to component tests. Reserve E2E for critical user paths.
Pitfall 2: Ignoring Flaky Tests
Symptom: “Just run it again” culture destroys confidence.
Solution: Track flakiness metrics. Quarantine or fix flaky tests immediately, before the team learns to ignore red runs.
Pitfall 3: Missing Test Isolation
Symptom: Tests pass individually but fail in suite, order-dependent failures.
Solution: Each test should be runnable in isolation. Use factories for setup, clean up in teardown.
Pitfall 4: Not Using Trace Viewer
Symptom: Spending hours debugging CI failures locally.
Solution: Enable trace: 'retain-on-failure' in config. Download trace files from CI artifacts and open with npx playwright show-trace trace.zip. The viewer shows DOM snapshots, network calls, console logs, and exact timing. It saves hours of debugging.
Pitfall 5: Mocking Everything
Symptom: All API calls mocked, tests pass but production breaks.
Solution: Mock external third-parties and error scenarios. Don’t mock your own API in E2E tests. That defeats the integration testing purpose.
When to Override the Default
Playwright holds as the default while the suite runs in CI, needs coverage beyond Chromium, and grows past what a single machine finishes in a reasonable time. Cypress is the better call for a team that debugs interactively all day inside a single-browser SPA and already budgets the Cloud subscription. For them the interactive runner is worth more than free shards.
Either way, the page objects, data-testid selectors, and API-created data carry over, so switching frameworks later costs less than it looks. Start with 5 to 10 critical-path tests and keep flaky ones out of the suite until they are fixed. Expand coverage once those tests catch a regression that would otherwise have shipped.
References
- Playwright Documentation - Official Playwright E2E testing framework docs
- Cypress Documentation - Official Cypress E2E testing guide
- The Practical Test Pyramid - Martin Fowler - Authoritative guide on balancing unit, integration, and E2E tests
- Test Pyramid - Martin Fowler bliki - Concise definition of the testing pyramid concept
- Playwright: Writing Tests - Auto-waiting, assertions, and test structure patterns
Related posts
A practical guide to building an org-level shared GitHub Actions platform: architecture decisions, security governance, adoption, and 7 costly mistakes.
Rushing feels fast but creates rework, bugs, and firefighting. Why pausing for refactoring, tests, and CI upkeep is an investment in speed, not lost speed.
A practical guide to consumer-driven contract testing with Pact in TypeScript microservices, catching breaking API changes before deployment.
Build a testing strategy for AWS Lambda, API Gateway, DynamoDB, and Step Functions with practical patterns for fast feedback and reliability.
Where AI-assisted code review catches what humans miss, where humans still excel, and how to build effective human-AI collaboration in your review process.