Playwright Testing: Write Browser Tests That Actually Work
Playwright Testing: Write Browser Tests That Actually Work
Browser testing has a reputation for being slow and flaky. Playwright, developed by Microsoft, has largely solved both problems. It supports Chromium, Firefox, and WebKit from a single API, auto-waits for elements before interacting with them, and runs tests in parallel across isolated browser contexts. This guide focuses on Playwright Test -- the built-in test runner -- and the patterns that produce reliable, maintainable test suites.
Setup
# Initialize Playwright in an existing project
npm init playwright@latest
# Or with Bun
bunx create-playwright
# This installs:
# - @playwright/test
# - playwright.config.ts
# - Sample test files
# - Browser binaries (Chromium, Firefox, WebKit)
The init command downloads actual browser binaries, not just drivers. This means your tests run against real browsers, not a simulated environment.
Configuration
playwright.config.ts controls everything:
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
// Reporter configuration
reporter: process.env.CI
? [["github"], ["html", { open: "never" }]]
: [["html", { open: "on-failure" }]],
use: {
baseURL: "http://localhost:3000",
screenshot: "only-on-failure",
trace: "on-first-retry",
video: "on-first-retry",
},
// Test against multiple browsers
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
// Mobile viewports
{
name: "mobile-chrome",
use: { ...devices["Pixel 5"] },
},
],
// Start your dev server before running tests
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
The webServer config is important -- Playwright starts your application automatically before running tests and stops it afterward. No separate terminal window needed.
Writing Tests
Basic Test Structure
import { test, expect } from "@playwright/test";
test("user can search for products", async ({ page }) => {
await page.goto("/");
await page.getByPlaceholder("Search products").fill("mechanical keyboard");
await page.getByRole("button", { name: "Search" }).click();
await expect(page.getByTestId("search-results")).toContainText(
"mechanical keyboard"
);
await expect(page.getByTestId("result-count")).toHaveText(/\d+ results/);
});
Locator Strategy
Playwright's locator API is the key to writing tests that do not break when the UI changes. Prefer these strategies, in order:
// 1. Role-based (best -- matches accessibility semantics)
page.getByRole("button", { name: "Submit" });
page.getByRole("heading", { name: "Dashboard" });
page.getByRole("link", { name: "Settings" });
// 2. Label-based (good for form fields)
page.getByLabel("Email address");
page.getByPlaceholder("Enter your email");
// 3. Text-based (good for visible content)
page.getByText("Welcome back");
page.getByText(/total: \$[\d.]+/i);
// 4. Test ID (stable, but less semantic)
page.getByTestId("submit-button");
page.getByTestId("user-profile-card");
// 5. CSS selectors (last resort)
page.locator(".card:has-text('Premium')");
page.locator("table >> tr >> nth=2");
Role-based locators test your application the way a user (or screen reader) interacts with it. If getByRole("button", { name: "Submit" }) breaks, it means a real user also cannot find the submit button. That is the kind of failure you want to catch.
Auto-Waiting
Playwright automatically waits for elements to be visible, enabled, and stable before interacting with them. You do not need to write explicit waits:
// Playwright waits for the button to exist, be visible,
// and be enabled before clicking
await page.getByRole("button", { name: "Save" }).click();
// Explicit waiting is only needed for time-dependent state
await expect(page.getByText("Saved successfully")).toBeVisible({
timeout: 10_000,
});
This auto-waiting is the single biggest reason Playwright tests are less flaky than Selenium or Cypress tests. You almost never need waitForSelector or sleep.
Authentication
Most applications require login. Playwright's storageState feature lets you authenticate once and reuse the session across tests.
Auth Setup
// tests/auth.setup.ts
import { test as setup, expect } from "@playwright/test";
const authFile = "tests/.auth/user.json";
setup("authenticate", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("[email protected]");
await page.getByLabel("Password").fill("testpassword");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByText("Dashboard")).toBeVisible();
await page.context().storageState({ path: authFile });
});
Using Auth in Config
// playwright.config.ts
export default defineConfig({
projects: [
// Setup project runs first
{ name: "setup", testMatch: /.*\.setup\.ts/ },
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
storageState: "tests/.auth/user.json",
},
dependencies: ["setup"],
},
],
});
Every test in the "chromium" project now starts already logged in. The setup runs once, not before every test.
Page Object Model
For larger test suites, encapsulate page interactions in page objects:
// tests/pages/dashboard.ts
import { type Page, type Locator, expect } from "@playwright/test";
export class DashboardPage {
readonly page: Page;
readonly heading: Locator;
readonly newProjectButton: Locator;
readonly projectList: Locator;
constructor(page: Page) {
this.page = page;
this.heading = page.getByRole("heading", { name: "Dashboard" });
this.newProjectButton = page.getByRole("button", {
name: "New Project",
});
this.projectList = page.getByTestId("project-list");
}
async goto() {
await this.page.goto("/dashboard");
await expect(this.heading).toBeVisible();
}
async createProject(name: string) {
await this.newProjectButton.click();
await this.page.getByLabel("Project name").fill(name);
await this.page.getByRole("button", { name: "Create" }).click();
await expect(this.page.getByText(`Project "${name}" created`)).toBeVisible();
}
}
// tests/e2e/dashboard.spec.ts
import { test, expect } from "@playwright/test";
import { DashboardPage } from "../pages/dashboard";
test("user can create a new project", async ({ page }) => {
const dashboard = new DashboardPage(page);
await dashboard.goto();
await dashboard.createProject("My New Project");
await expect(dashboard.projectList).toContainText("My New Project");
});
Visual Regression Testing
Playwright has built-in screenshot comparison:
test("homepage renders correctly", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveScreenshot("homepage.png", {
maxDiffPixelRatio: 0.01,
});
});
test("dashboard chart renders", async ({ page }) => {
await page.goto("/dashboard");
await expect(page.getByTestId("revenue-chart")).toHaveScreenshot(
"revenue-chart.png"
);
});
The first run generates baseline screenshots. Subsequent runs compare against them. When the UI intentionally changes, update baselines with npx playwright test --update-snapshots.
API Testing
Playwright includes APIRequestContext for testing APIs without a browser:
import { test, expect } from "@playwright/test";
test("API returns user list", async ({ request }) => {
const response = await request.get("/api/users");
expect(response.ok()).toBeTruthy();
const users = await response.json();
expect(users.length).toBeGreaterThan(0);
expect(users[0]).toHaveProperty("email");
});
This is useful for testing API endpoints alongside your E2E tests without the overhead of browser rendering.
CI Configuration
# GitHub Actions
name: E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
The --with-deps flag installs system dependencies (libraries needed by the browser binaries). The report artifact gives you an interactive HTML report for debugging failures in CI.
Debugging Failed Tests
# Run with the Playwright inspector (step through interactively)
npx playwright test --debug
# Run in headed mode (see the browser)
npx playwright test --headed
# View the trace from a failed CI run
npx playwright show-trace trace.zip
# Generate and open the HTML report
npx playwright show-report
The trace viewer is the most powerful debugging tool. It records every action, every network request, every DOM snapshot, and every console message during the test. When a CI test fails, download the trace artifact and step through exactly what happened.
The Bottom Line
Playwright has raised the floor for browser testing reliability. Auto-waiting eliminates the largest category of flaky tests. Parallel execution with isolated contexts eliminates the second largest category. The trace viewer makes debugging CI failures practical instead of guesswork. If your application has a UI, Playwright Test should be the first tool you reach for when writing E2E tests.