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('user@example.com', 'password123');
await loginPage.expectLoginSuccess();
});
test('invalid credentials show error', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', '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 (opens in new tab) - Official Playwright E2E testing framework docs
- Cypress Documentation (opens in new tab) - Official Cypress E2E testing guide
- The Practical Test Pyramid - Martin Fowler (opens in new tab) - Authoritative guide on balancing unit, integration, and E2E tests
- Test Pyramid - Martin Fowler bliki (opens in new tab) - Concise definition of the testing pyramid concept
- Playwright: Writing Tests (opens in new tab) - 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.
github-actions · ci-cd · devops +5
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.
code-review · ci-cd · security +7
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.
technical-debt · testing · ci-cd +2
Committing Bruno .bru files to the repo keeps the API contract in the same PR and history as the code. The only real tax is a deliberate secrets boundary.
testing · ci-cd · developer-experience +1
A production guide to feature flags in distributed systems, comparing LaunchDarkly, Unleash, and AWS AppConfig with examples for rollouts and A/B testing.
feature-flags · devops · ci-cd +5