End-to-End Testing (Playwright/Cypress)
Playwright vs Cypress
Both tools launch a real browser and let you script interactions against it, but they differ in architecture and day-to-day feel.
Playwright | Cypress | |
|---|---|---|
Browser engines | Chromium, Firefox, WebKit — all first-class | Chromium-family and Firefox; WebKit support is more limited |
Execution model | Runs outside the browser, drives it over a protocol; supports true parallel workers out of the box | Runs inside the browser event loop alongside your app |
Multi-tab / multi-origin | Native support for multiple tabs, windows, and origins | Historically limited to a single origin per test (improving over time) |
Auto-waiting | Built-in — waits for elements to be actionable before interacting | Built-in — similar retry/waiting behavior |
Language support | JavaScript, TypeScript, Python, Java, .NET | JavaScript, TypeScript |
Debugging experience | Trace viewer, codegen recorder, VS Code extension | Time-travel debugger in its own dashboard-style runner |
In practice, both catch the same class of bugs. Playwright tends to win teams that need cross-browser coverage or heavy parallelization; Cypress has a longer track record and a very approachable developer experience for teams that only need to cover Chromium-family browsers.
A worked example with Playwright
npm init playwright@latest), tests live in a tests/ or e2e/ directory and run against a real server — either one you start yourself or one Playwright starts for you via its config.e2e/homepage.spec.ts
import { test, expect } from '@playwright/test'
test('homepage has a working search link', async ({ page }) => {
// 1. Visit the real, running app
await page.goto('http://localhost:3000')
// 2. Interact with it like a user would
await page.getByRole('link', { name: 'Search' }).click()
// 3. Assert on the resulting state
await expect(page).toHaveURL(/\/search/)
await expect(
page.getByRole('heading', { name: 'Search' })
).toBeVisible()
})playwright.config.ts — start Next.js automatically
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
webServer: {
command: 'npm run build && npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: 'http://localhost:3000',
},
})Run the suite
npx playwright test npx playwright test --ui # interactive UI mode npx playwright show-report # HTML report from the last run
The same scenario in Cypress reads similarly — visit, interact, assert — just with Cypress's own chained API:
cypress/e2e/homepage.cy.ts
describe('homepage', () => {
it('has a working search link', () => {
cy.visit('http://localhost:3000')
cy.contains('a', 'Search').click()
cy.url().should('include', '/search')
cy.contains('h1', 'Search').should('be.visible')
})
})E2E tests drive a real browser against a real running app — no mocks — so they verify that every layer actually works together.
Playwright and Cypress are the two mainstream choices; Playwright offers broader cross-browser and parallelization support, Cypress offers a very approachable developer experience.
Both provide auto-waiting so tests do not need manual sleeps while the UI catches up.
Because E2E tests are slow to run and can be flaky, reserve them for a small set of critical user flows rather than every possible interaction.