Why Teams Switch From Cypress
Cypress was the gold standard for E2E testing from 2018-2022. Playwright (launched by Microsoft in 2020) has since overtaken it for many teams because of four specific limitations Cypress has that Playwright does not:
- Multiple browsers in one test run — Playwright tests against Chromium, Firefox, and WebKit simultaneously
- Multiple tabs and origins — Playwright can open two tabs and test cross-tab communication
- No port 4000 restriction — Playwright talks to any URL, including production
- Faster — Playwright uses a CDP/WebSocket bridge instead of running inside the browser iframe
Installation
pnpm add -D @playwright/test
npx playwright install # installs Chromium, Firefox, WebKit
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "mobile", use: { ...devices["iPhone 15"] } },
],
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
Writing Tests With Role-Based Locators
Playwright prefers getByRole() locators because they test accessibility at the same time:
import { test, expect } from "@playwright/test";
test("create a task", async ({ page }) => {
await page.goto("/projects/proj-123");
// Prefer getByRole over CSS selectors
await page.getByRole("button", { name: "Add Task" }).click();
await page.getByRole("textbox", { name: "Task title" }).fill("Fix login bug");
await page.getByRole("button", { name: "Save" }).click();
// Assert the task appears
await expect(page.getByText("Fix login bug")).toBeVisible();
await expect(page.getByRole("status")).toHaveText("Task created");
});
Test Fixtures for Auth
// e2e/fixtures.ts
import { test as base } from "@playwright/test";
type Fixtures = { authenticatedPage: Page };
export const test = base.extend<Fixtures>({
authenticatedPage: async ({ page }, use) => {
await page.goto("/login");
await page.getByLabel("Email").fill("test@example.com");
await page.getByLabel("Password").fill("testpass123");
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL("/dashboard");
await use(page);
},
});
// In tests:
import { test } from "./fixtures";
test("view dashboard", async ({ authenticatedPage: page }) => {
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
});
API Testing
Playwright can test your API routes directly — no browser needed:
import { test, expect } from "@playwright/test";
test("POST /api/tasks", async ({ request }) => {
const response = await request.post("/api/tasks", {
data: { title: "New task", projectId: "proj-123" },
headers: { Authorization: "Bearer test-token" },
});
expect(response.status()).toBe(201);
const body = await response.json();
expect(body).toMatchObject({ title: "New task" });
});
Codegen — Record Your Tests
# Open the browser and record interactions as Playwright test code
npx playwright codegen http://localhost:3000
The codegen recorder generates test code in real-time as you click through the app. It is a starting point — you will clean it up — but it dramatically speeds up writing the initial test skeleton.
Parallel Runs and CI
# Run all tests in parallel
npx playwright test
# Run with UI for debugging
npx playwright test --ui
# CI: run headless with retries
CI=true npx playwright test --retries=2
References: Playwright · GitHub · VS Code extension