95% unit test coverage measures isolated functions. Signup, login, and payment move through frontend, API, authentication, and session at the same time, and that path stays untested. The bugs that reach your users show up in the integration between components and in the flows nobody ran end to end.
Playwright next to Cypress
Both drive a real browser and intercept network traffic. Playwright gives you parallel execution per worker, Chromium, Firefox, and WebKit in one setup, and multiple tabs and iframes through one API. Teams already writing tests in Vitest or Jest recognize the Playwright API in the first session. Cypress remains a reference for developer experience in the Node ecosystem, and migration costs real money in both directions, so pick once.
// 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 test: the full login
The login flow validates frontend, API, authentication, and session in a single test. Start there.
// 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')
})
The Playwright best practices page asks tests to depend on attributes the user sees and to avoid the DOM structure. Preference order: role (getByRole), label, text, then test id. The locators guide confirms data-testid is the fallback when role and text cannot identify the element alone. Layout CSS classes break on the first design pass.
Page Objects for repeated selectors
Twenty tests pointing at the same login form become expensive to maintain once the markup changes. Page Objects gather the selectors into one class per 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')
})
When the frontend changes, you fix one file and the twenty tests stay green.
Error states require network interception
API timeouts, dropped connections, and loading states never show up in a unit test. page.route intercepts the call and returns the failure:
test('shows error when API is down', async ({ page }) => {
// Intercept the API and abort the connection
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()
})
The test asserts the message and the retry button, which is what the user sees when the backend goes down.
Test data created and deleted by the fixture
E2E needs predictable data. Playwright fixtures create the user through the API before the test and delete it after:
// e2e/fixtures.ts
import { test as base } from '@playwright/test'
export const test = base.extend<{ testUser: User }>({
testUser: async ({ request }, use) => {
// Create the test user through the 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)
// Delete after the test
await request.delete(`/api/test/users/${user.id}`)
},
})
Data created per test removes dependencies between cases and makes execution order irrelevant.
CI with browsers and artifacts
The pipeline installs the browsers, runs the suite, and publishes the report when something fails:
- 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/
With trace: 'on-first-retry', CI records the session that reproduces the bug. You open the trace instead of reproducing the scenario by hand.
Where E2E stops paying off
E2E tests run slow and cost real money to maintain. John Micco, in Flaky Tests at Google and How We Mitigate Them (Google Testing Blog, 2016), reported 1.5% flaky results across Google's test corpus and some level of flakiness in 16% of tests. Ham Vocke, in The Practical Test Pyramid (martinfowler.com, 2018), argues for many unit tests, some integration tests, and few end-to-end tests. Reserve E2E for login, checkout, account creation, and payment. Everything else stays with unit and integration tests.
References
- Playwright docs: Best Practices
- Playwright docs: Locators
- The Practical Test Pyramid, Ham Vocke (martinfowler.com, 2018)
- Test Pyramid, Martin Fowler (martinfowler.com, 2012), on Mike Cohn's metaphor in Succeeding with Agile (2009)
- Flaky Tests at Google and How We Mitigate Them, John Micco (Google Testing Blog, 2016)
Enjoyed this content?
I build web products and AI solutions the right way — solid architecture, maintainable code, and real delivery.
Let's talk