Playwright Reference: Locators, Actions, Page Objects, API Mocking & CI
Playwright is Microsoft’s E2E testing framework supporting Chromium, Firefox, and WebKit in a single API. The key advantage over Cypress: true multi-browser support, parallel execution out of the box, and a network interception API that actually works at the browser level. The most important concept to get right early: Playwright uses auto-waiting — you never need sleep() or explicit waits. Every action (click, fill, etc.) automatically waits for the element to be visible, stable, and enabled.
1. Setup, Config & First Test
Install, playwright.config.ts, browsers, base URL, and test structure
# npm init playwright@latest
# npm install -D @playwright/test
# npx playwright install # downloads browsers (~300MB)
// playwright.config.ts:
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true, // all tests run in parallel by default
retries: process.env.CI ? 2 : 0, // retry on CI, not locally
workers: process.env.CI ? 1 : undefined,
reporter: [["html"], ["list"]],
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry", // capture trace on retry for debugging
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
{ name: "Mobile Chrome", use: { ...devices["Pixel 5"] } },
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
// e2e/login.spec.ts:
import { test, expect } from "@playwright/test";
test("user can log in", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("alice@example.com");
await page.getByLabel("Password").fill("secret123");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL("/dashboard");
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
});
2. Locators — The Right Way to Find Elements
Role, label, text, test-id locators — and why to avoid CSS selectors
// Prefer user-visible locators over CSS/XPath — they survive refactors:
// By role (ARIA role — most resilient):
page.getByRole("button", { name: "Submit" })
page.getByRole("textbox", { name: "Email" })
page.getByRole("link", { name: "Home" })
page.getByRole("heading", { level: 1 })
page.getByRole("checkbox", { name: "Remember me" })
page.getByRole("listitem") // all li elements
page.getByRole("dialog")
// By label (form inputs):
page.getByLabel("Email address") // finds input with associated label
page.getByLabel("Password")
// By placeholder:
page.getByPlaceholder("Search...")
// By text (for non-interactive elements):
page.getByText("Welcome back, Alice")
page.getByText(/error/i) // regex
// By alt text (images):
page.getByAltText("Company logo")
// By test ID (for complex cases where other locators don't work):
// Add data-testid="submit-btn" to HTML → page.getByTestId("submit-btn")
// Chaining — scope to a parent:
const loginForm = page.getByRole("form", { name: "Login" });
loginForm.getByLabel("Email").fill("alice@example.com");
// Filtering:
page.getByRole("listitem").filter({ hasText: "Alice" })
page.getByRole("listitem").nth(0) // first item
page.getByRole("listitem").last()
// Avoid (fragile): page.locator(".btn-primary"), page.locator("//button[1]")
3. Actions, Assertions & Auto-Waiting
Click, fill, select, upload, keyboard — and expect assertions with custom timeouts
// Actions (all auto-wait for element to be visible + stable + enabled):
await page.getByLabel("Email").fill("alice@example.com"); // clears then types
await page.getByLabel("Email").type("alice@example.com"); // types char by char (for autocomplete)
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("button", { name: "Submit" }).dblclick();
await page.getByRole("checkbox").check();
await page.getByRole("checkbox").uncheck();
await page.getByRole("combobox").selectOption("London");
await page.getByRole("combobox").selectOption({ label: "London" });
// File upload:
await page.getByLabel("Upload file").setInputFiles("path/to/file.pdf");
// Keyboard:
await page.keyboard.press("Tab");
await page.keyboard.press("Enter");
await page.keyboard.press("Control+A");
// Assertions (expect auto-waits up to timeout — default 5s):
await expect(page).toHaveURL("/dashboard");
await expect(page).toHaveTitle(/My App/);
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeChecked();
await expect(locator).toHaveText("Hello");
await expect(locator).toContainText("Hello");
await expect(locator).toHaveValue("alice@example.com"); // input value
await expect(locator).toHaveCount(5); // number of matching elements
// Custom timeout:
await expect(locator).toBeVisible({ timeout: 10_000 });
// Soft assertions — don't stop test on failure:
await expect.soft(locator).toBeVisible();
await expect.soft(locator).toHaveText("Hello");
// All soft assertions are reported together at the end
4. Page Object Model & Fixtures
Encapsulate pages in classes, share state via fixtures, and bypass authentication
// Page Object Model — encapsulate page interactions:
// e2e/pages/LoginPage.ts
import { type Page, type Locator } from "@playwright/test";
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel("Email");
this.passwordInput = page.getByLabel("Password");
this.submitButton = page.getByRole("button", { name: "Sign in" });
}
async goto() {
await this.page.goto("/login");
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
await this.page.waitForURL("/dashboard");
}
}
// Custom fixtures — share authenticated state:
// e2e/fixtures.ts
import { test as base } from "@playwright/test";
import { LoginPage } from "./pages/LoginPage";
type Fixtures = { loggedInPage: Page };
export const test = base.extend<Fixtures>({
loggedInPage: async ({ page }, use) => {
const login = new LoginPage(page);
await login.goto();
await login.login("alice@example.com", "secret123");
await use(page); // yield authenticated page to test
},
});
// Use in tests:
import { test, expect } from "./fixtures";
test("authenticated user sees dashboard", async ({ loggedInPage }) => {
await expect(loggedInPage.getByRole("heading", { name: "Dashboard" })).toBeVisible();
});
5. API Mocking, Tracing & CI
Intercept network requests, route mocking, HAR recording, and GitHub Actions setup
// Network interception — mock API responses:
await page.route("/api/users", (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 1, name: "Alice" }]),
});
});
// Intercept + modify (proxy-style):
await page.route("/api/**", async (route) => {
const response = await route.fetch();
const json = await response.json();
json.injected = true;
await route.fulfill({ response, json });
});
// Block specific resources (speed up tests):
await page.route("**/*.{png,jpg,gif,svg}", route => route.abort());
await page.route("**google-analytics**", route => route.abort());
// Assert request was made:
const responsePromise = page.waitForResponse("/api/users");
await page.getByRole("button", { name: "Load Users" }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);
// Tracing — record + replay test execution:
// playwright.config.ts: trace: "on-first-retry"
// View trace: npx playwright show-trace trace.zip
// GitHub Actions:
// - uses: microsoft/playwright-github-action@v1
// Or manually:
// - run: npx playwright install --with-deps chromium
// - run: npx playwright test
// - uses: actions/upload-artifact@v4
// if: always()
// with:
// name: playwright-report
// path: playwright-report/
// Headed mode (see browser):
// npx playwright test --headed
// npx playwright test --debug # step through with DevTools
// npx playwright codegen http://localhost:3000 # record interactions to code
Track Node.js and testing toolchain releases at ReleaseRun. Related: Vitest Reference | Next.js App Router Reference | TypeScript Reference
🔍 Free tool: npm Package Health Checker — check Playwright and test utility packages for known CVEs and latest version before CI upgrades.
Founded
2023 in London, UK
Contact
hello@releaserun.com