If you have 95% unit test coverage but never tested the complete flow from signup to payment, you have a false sense of security. The bugs that hurt most in production live in the integration between components, in user paths nobody tested end-to-end.
Why Playwright and not Cypress
Both are good. Playwright has practical advantages: native support for multiple tabs and iframes, parallel execution per test, more reliable auto-waiting, and support for Chromium, Firefox, and WebKit in the same setup. For teams already using Vitest or Jest, Playwright's API integrates better with the existing ecosystem.
// playwright.config.ts
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 30000,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: 'http://localhost:3000',
screenshot: 'only-on-failure',
trace: 'on-first-retry',
},
webServer: {
command: 'pnpm dev',
port: 3000,
reuseExistingServer: !process.env.CI,
},
})
Your first E2E test
An E2E test worth writing: the complete login flow. Not the most complex test, but it validates entire layers: frontend, API, authentication, session.
// e2e/login.spec.ts
import { test, expect } from '@playwright/test'
test('user can log in and see dashboard', async ({ page }) => {
await page.goto('/login')
await page.fill('[data-testid="email"]', 'user@example.com')
await page.fill('[data-testid="password"]', 'password123')
await page.click('[data-testid="submit"]')
await expect(page).toHaveURL('/dashboard')
await expect(page.locator('h1')).toContainText('Welcome')
})
Notice: data-testid as selectors, not CSS classes or visible text. Stable selectors survive UI changes.
Page Object Model: don't repeat selectors
When you have 20 tests using the same login form, duplicated selectors become heavy maintenance. Page Objects encapsulate interaction with each page:
// e2e/pages/LoginPage.ts
import { Page, expect } from '@playwright/test'
export class LoginPage {
constructor(private readonly page: Page) {}
async goto() {
await this.page.goto('/login')
}
async login(email: string, password: string) {
await this.page.fill('[data-testid="email"]', email)
await this.page.fill('[data-testid="password"]', password)
await this.page.click('[data-testid="submit"]')
}
async expectError(message: string) {
await expect(this.page.locator('[data-testid="error"]'))
.toContainText(message)
}
}
// e2e/login.spec.ts
test('failed login shows error', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('wrong@example.com', 'bad')
await loginPage.expectError('Invalid credentials')
})
Testing error states
Where E2E adds the most value is testing error paths that unit tests don't cover: API timeouts, network errors, loading states, offline behavior.
test('shows error when API is down', async ({ page }) => {
// Intercept API and simulate failure
await page.route('**/api/orders', route => route.abort('connectionrefused'))
await page.goto('/orders')
await expect(page.locator('[data-testid="error-message"]'))
.toContainText('Could not load orders')
await expect(page.locator('[data-testid="retry-button"]'))
.toBeVisible()
})
Test data: seeding and cleanup
E2E tests need consistent data. Your test data should be created before each test and removed after:
// e2e/fixtures.ts
import { test as base } from '@playwright/test'
export const test = base.extend<{ testUser: User }>({
testUser: async ({ request }, use) => {
// Create test user via API
const res = await request.post('/api/test/users', {
data: { email: `test-${Date.now()}@example.com`, password: 'test123' }
})
const user = await res.json()
await use(user)
// Cleanup
await request.delete(`/api/test/users/${user.id}`)
},
})
Running in CI
Playwright in CI needs specific steps: install browsers, run with --reporter=html, and upload artifacts on failure. In GitHub Actions:
- name: Install Playwright
run: pnpm exec playwright install --with-deps
- name: Run E2E tests
run: pnpm test:e2e
- name: Upload test report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
Where to stop
Don't test everything with E2E. E2E tests are slow, fragile, and expensive to maintain. Use them for critical user flows: login, checkout, account creation, payment flows. For everything else, unit and integration tests are enough.
The rule: if a failure in that flow causes revenue or data loss, use E2E. If it's a secondary feature, use unit or integration tests.
Enjoyed this content?
I build web products and AI solutions the right way — solid architecture, maintainable code, and real delivery.
Let's talk