Cheatsheet Playwright
Framework da Microsoft para testes end-to-end e automação web
Playwright
Installation and Setup
Start a project
npm init playwright@latest
Official wizard that creates the full structure. Generates playwright.config.ts, the tests/ folder and GitHub Actions. Asks for language, browsers and CI.
File structure
tests/
login.spec.ts
cart.spec.ts
fixtures/
auth.ts
playwright.config.ts
package.jsonTests live in .spec.ts files. fixtures/ stores custom fixtures. The config sits at the root. Each file can have multiple tests organized by feature.
Global setup and teardown
export default defineConfig({
globalSetup: "./global-setup.ts",
globalTeardown: "./global-teardown.ts",
});
// global-setup.ts
export default async function () {
// seed DB, create test data
}globalSetup runs once before all tests. globalTeardown runs at the end. Ideal for DB seeding or cleaning up resources. It receives config the an argument.
Install browsers
npx playwright install npx playwright install chromium npx playwright install --with-deps
install downloads Chromium, Firefox and WebKit. --with-deps installs system dependencies (Linux). You can install just one specific browser.
First test
import { test, expect } from "@playwright/test";
test("home page has title", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/My App/);
});test() defines a test with a name and callback. page is injected via fixtures. expect() makes assertions with auto-retry. goto() navigates to the URL (uses baseURL).
TypeScript and imports
import { test, expect, type Page } from "@playwright/test";
async function login(page: Page, user: string) {
await page.goto("/login");
await page.getByLabel("Email").fill(user);
await page.getByRole("button", { name: "Sign in" }).click();
}type Page types parameters in helpers. Helper functions receive page the an argument. TypeScript gives autocomplete and detects errors. Fully supported natively.
Codegen (record actions)
npx playwright codegen wikipedia.org npx playwright codegen --target=python npx playwright codegen --device="iPhone 13"
codegen opens a browser and records actions the code. --target changes the output language. --device simulates mobile devices. Great for learning selectors.
Projects (multi-browser)
export default defineConfig({
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "mobile", use: { ...devices["iPhone 13"] } },
],
});projects runs the tests across multiple browsers. devices pre-configures viewport and user-agent. Each project can have its own config. Tests run on all of them by default.
Environments and variables
// .env
BASE_URL=http://localhost:3000
// playwright.config.ts
use: {
baseURL: process.env.BASE_URL || "http://localhost:3000",
}
// run with env:
// BASE_URL=https://staging.example.com npx playwright testprocess.env reads environment variables. It lets you test against staging or production. dotenv can load .env files. Never commit credentials.
Configuration file
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
timeout: 30000,
retries: 2,
workers: 4,
use: {
baseURL: "http://localhost:3000",
headless: true,
},
});defineConfig types the configuration. testDir defines where the tests are. timeout is the limit per test. use configures options shared by all tests.
Automatic web server
export default defineConfig({
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
});webServer starts the app before the tests. command is the startup command. reuseExistingServer avoids restarting in dev. In CI, it always forces a new server.
Extensions and plugins
// eslint-plugin-playwright
// .eslintrc
{
"plugins": ["playwright"],
"extends": ["plugin:playwright/recommended"]
}
// @axe-core/playwright (accessibility)
import AxeBuilder from "@axe-core/playwright";eslint-plugin-playwright enforces best practices via lint. @axe-core/playwright tests accessibility. The community has plugins for reporting, visuals and more. Install via npm.
Test Structure
Simple test
test("logs in successfully", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("ana@site.com");
await page.getByLabel("Password").fill("123456");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL("/dashboard");
});Each test() receives fixtures via destructuring. page is the isolated browser context. The test fails if any expect() does not pass. Names should describe the behavior.
Available fixtures
test("example", async ({
page, // Page - main page
context, // BrowserContext - isolated context
browser, // Browser - browser instance
request, // APIRequestContext - HTTP requests
}) => {
// ...
});page is the page with all interactions. context lets you create multiple pages. browser is the low-level instance. request makes direct API calls.
Parallel tests
test.describe.configure({ mode: "parallel" });
test("test A", async ({ page }) => { });
test("test B", async ({ page }) => { });
test("test C", async ({ page }) => { });mode: "parallel" runs the group tests simultaneously. By default, tests in the same file are sequential. Each test has an isolated browser. Speeds up large suites.
Soft assertions
test("checks multiple fields", async ({ page }) => {
await expect.soft(page.getByText("Name")).toHaveText("Anna");
await expect.soft(page.getByText("Email")).toHaveText("ana@site.com");
await expect.soft(page.getByText("Age")).toHaveText("30");
// all are checked even if one fails
});expect.soft() does not stop the test on failure. All assertions run. The report shows all failures. Ideal for checking multiple fields at once.
Group with describe
test.describe("Shopping cart", () => {
test("adds item", async ({ page }) => { });
test("removes item", async ({ page }) => { });
test("calculates total", async ({ page }) => { });
});test.describe() groups related tests. The name appears the a prefix in the report. It can be nested. Useful for organizing by feature or module.
Custom fixtures
import { test the base } from "@playwright/test";
export const test = base.extend({
loggedInPage: async ({ page }, use) => {
await page.goto("/login");
await page.getByLabel("Email").fill("admin@site.com");
await page.getByRole("button").click();
await use(page);
},
});
// usage: test("...", async ({ loggedInPage }) => { });base.extend() creates custom fixtures. The code before use() is the setup. use(page) hands it to the test. Code after is teardown. Reusable across multiple files.
Serial tests
test.describe.configure({ mode: "serial" });
test("creates account", async ({ page }) => { });
test("logs in", async ({ page }) => { });
test("checks profile", async ({ page }) => { });mode: "serial" guarantees order and stops if one fails. Useful when tests depend on the previous state. If the first fails, the rest are skipped. Avoids a cascade of errors.
Hooks beforeEach/afterEach
test.describe("Admin panel", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/admin/login");
await login(page);
});
test.afterEach(async ({ page }) => {
await page.evaluate(() => localStorage.clear());
});
});beforeEach runs before each test in the group. afterEach runs after. Ideal for repetitive setup like login. Reduces code duplication between tests.
Per-file options (test.use)
test.use({
baseURL: "http://localhost:8080",
viewport: { width: 1920, height: 1080 },
locale: "pt-PT",
timezoneId: "Europe/Lisbon",
});test.use() configures all tests in the file. viewport sets the resolution. locale and timezoneId test internationalization. It overrides the global config.
Retries
// global config:
export default defineConfig({
retries: process.env.CI ? 2 : 0,
});
// per test:
test("unstable operation", async ({ page }) => { });
test.describe.configure({ retries: 3 });retries re-runs tests that fail. In CI use 2, locally 0. Flaky tests pass on retry. The report shows the attempts. Do not abuse it — fix the root cause.
Hooks beforeAll/afterAll
test.describe("API tests", () => {
test.beforeAll(async () => {
await seedDatabase();
});
test.afterAll(async () => {
await cleanupDatabase();
});
});beforeAll runs once before all tests in the group. afterAll runs once at the end. For expensive setup (DB seed, creating a server). It does not receive page by default.
Skip and only
test.skip("broken test", async ({ page }) => { });
test.only("focus on this", async ({ page }) => { });
test.fixme("needs fixing", async ({ page }) => { });
// conditional:
test.skip(process.env.CI === "true", "Local only");test.skip() ignores the test. test.only() runs only that one (debug). test.fixme() marks it the known broken. Conditional skip accepts a boolean and a reason.
Annotations and metadata
test("checkout", async ({ page }) => {
test.slow(); // triples the timeout
test.setTimeout(60000); // custom timeout
});
// annotations in the report:
test.info().annotations.push({
type: "issue",
description: "https://github.com/app/issues/42",
});test.slow() triples the timeout for slow tests. setTimeout() sets a custom limit. annotations adds metadata to the HTML report. Useful to link to issues.
Locators
By role (recommended)
page.getByRole("button", { name: "Submit" })
page.getByRole("heading", { name: "Welcome" })
page.getByRole("link", { name: "About" })
page.getByRole("checkbox", { name: "I accept" })getByRole() locates by accessibility role. It is the method recommended by the Playwright team. name filters by the accessible text. Reflects how users and screen readers see the page.
By test id
page.getByTestId("btn-submit")
page.getByTestId("nav-menu")
// in the HTML:
// <button data-testid="btn-submit">Submit</button>getByTestId() uses the data-testid attribute. A stable selector that does not change with CSS or text. Configure the attribute in the config if different. A last resort when role/text are not enough.
Position and navigation
list.first()
list.last()
list.nth(2)
// navigating the hierarchy:
row.locator("span") // descendant
page.locator("div").locator("p") // chainfirst(), last() and nth() select by position. locator() inside another locates descendants. Chaining narrows down progressively. Index is 0-based.
Locators vs ElementHandle
// Recommended: Locator (lazy, auto-wait)
const btn = page.getByRole("button", { name: "OK" });
await btn.click();
// Avoid: ElementHandle (eager, no retry)
const el = await page.$(".button");
await el?.click();Locator is lazy and re-evaluates on each action. It auto-waits and retries automatically. ElementHandle is eager and can become stale. Always prefer Locators — they are more robust.
By text
page.getByText("Welcome")
page.getByText("Welcome", { exact: true })
page.getByText(/welcome/i)getByText() locates by visible text. Without exact, it does a partial match. exact: true requires a full match. Accepts regex for flexible patterns.
By alt and title
page.getByAltText("Company logo")
page.getByTitle("Settings")getByAltText() locates images by their alt text. getByTitle() locates by the title attribute (tooltip). Both are accessible and descriptive. Prefer alt for images.
Locate by content
// element that contains another:
page.locator("div", { has: page.getByText("Price") })
// element with specific text:
page.locator("li", { hasText: "Product A" })
// layout: item to the right of another
page.getByText("Total").locator("..")has locates a parent that contains a specific child. hasText filters by inner text. .. goes up to the parent element. Useful for locating containers without a test-id.
By label
page.getByLabel("Email")
page.getByLabel("Password", { exact: true })getByLabel() locates inputs by their associated label. Ideal for forms. Works with <label> and aria-label. More resilient than CSS selectors.
CSS and XPath
page.locator(".button.primary")
page.locator("#login-form")
page.locator("xpath=//div[@class='card']")
page.locator("div.card >> text=Details")locator() accepts CSS and XPath selectors. xpath= prefixes an explicit XPath. >> chains selectors. Use the a last resort — prefer semantic locators.
Multiple elements
const items = page.getByRole("listitem");
const count = await items.count();
for (let i = 0; i < count; i++) {
const text = await items.nth(i).textContent();
console.log(text);
}
// or: allTextContents()
const texts = await items.allTextContents();count() returns how many elements match. nth(i) accesses each one. allTextContents() extracts the text of all of them. Locators are lazy — they evaluate on interaction.
By placeholder
page.getByPlaceholder("name@example.com")
page.getByPlaceholder("Search...")getByPlaceholder() locates by the placeholder attribute. Useful when there is no visible label. Less robust than getByLabel(). The placeholder may change with redesigns.
Filter locators
const rows = page.getByRole("listitem");
rows.filter({ hasText: "Active" })
rows.filter({ has: page.getByRole("button") })
rows.filter({ hasText: "Anna" }).filter({ hasText: "Admin" })filter() refines an existing locator. hasText filters by contained text. has filters by a child element. You can chain multiple filters for precision.
Wait for a locator
await page.getByText("Loading").waitFor({ state: "hidden" });
await page.getByRole("dialog").waitFor({ state: "visible" });
await locator.waitFor({ timeout: 10000 });waitFor() waits until the element is in the desired state. state: "visible" waits for it to appear. state: "hidden" waits for it to disappear. A custom timeout overrides the global one.
Actions and Interactions
Click
await locator.click();
await locator.click({ button: "right" });
await locator.click({ clickCount: 2 });
await locator.click({ modifiers: ["Control"] });click() auto-waits for visibility and stability. button: "right" is a right click. clickCount: 2 is a double click. modifiers simulates Ctrl/Shift+click.
Select / dropdown
await select.selectOption("pt");
await select.selectOption({ label: "Portuguese" });
await select.selectOption({ value: "pt" });
await select.selectOption(["a", "b"]); // multi-selectselectOption() chooses by value, label or index. For multi-select, pass an array. It fires a change event. Works with native <select>.
Scroll
await locator.scrollIntoViewIfNeeded(); await page.mouse.wheel(0, 500); await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
scrollIntoViewIfNeeded() makes the element visible. mouse.wheel() scrolls by pixels. evaluate() runs native JS to scroll. Click actions already scroll automatically.
Actions in sequence
await page.getByLabel("Name").fill("Anna");
await page.getByLabel("Email").fill("ana@site.com");
await page.getByRole("combobox").selectOption("PT");
await page.getByRole("checkbox").check();
await page.getByRole("button", { name: "Register" }).click();Each action auto-waits before running. You do not need sleep() or manual waits. The sequence is deterministic. If an element does not appear, the test fails with a clear timeout.
Fill and clear
await input.fill("new text");
await input.clear();
await input.fill(""); // also clears
await input.type("slow", { delay: 100 });fill() sets the value instantly. clear() clears the field. type() simulates typing key by key with delay. fill() fires input/change events.
Hover and focus
await locator.hover();
await locator.focus();
await locator.blur();
// hover with position:
await locator.hover({ position: { x: 10, y: 5 } });hover() moves the mouse over the element (tooltips, menus). focus() gives keyboard focus. blur() removes focus. position specifies the exact hover point.
Action + navigation
await Promise.all([
page.waitForURL("**/dashboard"),
button.click(),
]);
// or with waitForNavigation:
await Promise.all([
page.waitForNavigation(),
link.click(),
]);Promise.all() waits for navigation and click simultaneously. Avoids race conditions. waitForURL() waits for a specific URL. Essential pattern for clicks that navigate.
Keys and shortcuts
await input.press("Enter");
await input.press("Tab");
await page.keyboard.press("Control+A");
await page.keyboard.type("Hello world");
await page.keyboard.down("Shift");
await page.keyboard.up("Shift");press() simulates a key on the element. keyboard.press() sends it globally. Combinations with + (Control+A). down()/up() to hold it pressed.
Drag and drop
await source.dragTo(target); // or manually: await source.hover(); await page.mouse.down(); await target.hover(); await page.mouse.up();
dragTo() drags one element onto another. For fine control use mouse.down()/mouse.up(). Works with DnD libraries. Auto-waits for both elements.
Evaluate (JavaScript)
const title = await page.evaluate(() => document.title);
await page.evaluate((el) => el.classList.add("active"), locator);
const data = await page.evaluate(() => JSON.parse(localStorage.getItem("app")));evaluate() runs JavaScript in the browser. Returns serializable values. You can pass elements the an argument. Use it when there is no Playwright API for the action.
Checkbox and radio
await checkbox.check(); await checkbox.uncheck(); await radio.check(); // selects // check the state: const checked = await checkbox.isChecked();
check() checks it (idempotent). uncheck() unchecks it. For radio buttons, check() selects the option. isChecked() returns the current state the a boolean.
File upload
await input.setInputFiles("photo.png");
await input.setInputFiles(["a.png", "b.png"]);
// without a visible input (filechooser):
const [fileChooser] = await Promise.all([
page.waitForEvent("filechooser"),
button.click(),
]);
await fileChooser.setFiles("doc.pdf");setInputFiles() sets files on a file input. For upload via button, use the filechooser event. Accepts paths or buffers. Multiple files via an array.
Touch and mobile
await locator.tap(); await page.touchscreen.tap(100, 200); // swipe: await page.touchscreen.tap(50, 300); // use mouse to simulate a swipe await page.mouse.move(50, 300); await page.mouse.down(); await page.mouse.move(300, 300); await page.mouse.up();
tap() simulates a tap on mobile. touchscreen.tap() with coordinates. For a swipe use a mouse sequence. Set hasTouch: true in the project to emulate touch.
Assertions
Visibility
await expect(locator).toBeVisible(); await expect(locator).toBeHidden(); await expect(locator).toBeAttached(); await expect(locator).not.toBeAttached();
toBeVisible() checks it is visible in the viewport. toBeHidden() checks it is hidden or absent. toBeAttached() checks presence in the DOM (even if hidden). Auto-retry until timeout.
Count
await expect(items).toHaveCount(3); await expect(items).not.toHaveCount(0);
toHaveCount() checks the number of matching elements. Auto-retry until the count stabilizes. Useful for dynamic lists after filters or additions.
Negation
await expect(locator).not.toBeVisible();
await expect(locator).not.toHaveText("Error");
await expect(page).not.toHaveURL("/login");.not negates any assertion. It also auto-retries (waits for the negative condition). Useful to check that errors do not appear or modals have closed.
Assertions in sequence
const card = page.getByTestId("product-1");
await expect(card).toBeVisible();
await expect(card.getByRole("heading")).toHaveText("Product A");
await expect(card.getByText("Price")).toContainText("19.99€");
await expect(card.getByRole("button")).toBeEnabled();Chain assertions to verify complete components. Each expect() waits independently. If one fails, the test stops (except soft). Organize by visual order for easy debugging.
Text
await expect(locator).toHaveText("Hello World");
await expect(locator).toContainText("World");
await expect(locator).toHaveText(/hello/i);
await expect(list).toHaveText(["A", "B", "C"]);toHaveText() checks exact text (normalizes whitespace). toContainText() checks a substring. Accepts regex. For lists, pass an array to check all items.
Attributes and CSS
await expect(link).toHaveAttribute("href", "/about");
await expect(el).toHaveClass(/active/);
await expect(el).toHaveCSS("color", "rgb(255, 0, 0)");
await expect(el).toHaveId("main-content");toHaveAttribute() checks HTML attributes. toHaveClass() checks classes (accepts regex). toHaveCSS() checks computed styles. toHaveId() checks the ID.
Auto-retry and timeout
// global timeout: 5s by default
// custom timeout per assertion:
await expect(locator).toBeVisible({ timeout: 10000 });
// assertions retry automatically
// until timeout — never use sleep()Assertions poll automatically until they pass or expire. A custom timeout overrides the global one (5s). Removes the need for waitForTimeout(). Makes tests deterministic.
Input value
await expect(input).toHaveValue("abc");
await expect(input).toHaveValue(/@site\.com$/);
await expect(input).toBeEmpty();toHaveValue() checks the current value of the input. Accepts an exact string or regex. toBeEmpty() checks an empty field. Works with text, number and date inputs.
Page (URL and title)
await expect(page).toHaveURL("/dashboard");
await expect(page).toHaveURL(/.*\/dashboard/);
await expect(page).toHaveTitle("Dashboard - App");
await expect(page).toHaveTitle(/Dashboard/);toHaveURL() checks the current URL (accepts regex). toHaveTitle() checks the page title. Auto-retry waits for navigation to complete. Essential after redirects.
Generic assertions
import { expect } from "@playwright/test";
expect(2 + 2).toBe(4);
expect([1, 2, 3]).toContain(2);
expect({ name: "Anna" }).toEqual({ name: "Anna" });
expect("hello world").toMatch(/world/);Playwright's expect includes generic assertions. toBe() compares values. toContain() checks inclusion. toEqual() compares objects. No auto-retry (they are synchronous).
Element state
await expect(checkbox).toBeChecked(); await expect(checkbox).not.toBeChecked(); await expect(button).toBeEnabled(); await expect(button).toBeDisabled(); await expect(input).toBeFocused(); await expect(input).toBeEditable();
toBeChecked() for checkboxes/radios. toBeEnabled()/toBeDisabled() check interactivity. toBeFocused() checks active focus. toBeEditable() checks it accepts input.
Screenshot comparison
await expect(page).toHaveScreenshot("home.png");
await expect(locator).toHaveScreenshot("card.png");
await expect(page).toHaveScreenshot("home.png", {
maxDiffPixelRatio: 0.01,
});toHaveScreenshot() compares against a reference screenshot. On the first run it creates the baseline. maxDiffPixelRatio sets the tolerance. Detects visual regressions automatically.
API response assertions
const response = await request.get("/api/users");
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.users).toHaveLength(5);The request fixture makes direct HTTP calls. ok() checks a 2xx status. status() returns the exact code. json() parses the body. Ideal for testing APIs without UI.
Navigation and Page
Go to URL
await page.goto("https://site.com");
await page.goto("/login"); // uses baseURL
await page.goto("/admin", { waitUntil: "networkidle" });goto() navigates to a URL. With baseURL configured, relative paths work. waitUntil controls when it is considered loaded: load, domcontentloaded or networkidle.
Current URL and title
const url = page.url(); const title = await page.title(); const content = await page.content();
page.url() returns the current URL (synchronous). page.title() returns the title (async). page.content() returns the full HTML. Useful for debugging and custom assertions.
Device emulation
import { devices } from "@playwright/test";
test.use({ ...devices["iPhone 13"] });
// or inline:
test.use({
viewport: { width: 375, height: 812 },
userAgent: "Mozilla/5.0 (iPhone...)",
hasTouch: true,
isMobile: true,
});devices has presets for viewport, UA and touch. hasTouch enables touch events. isMobile enables mobile behavior. Test responsiveness without CSS hacks.
Back / forward / reload
await page.goBack();
await page.goForward();
await page.reload();
await page.reload({ waitUntil: "networkidle" });goBack() and goForward() navigate the history. reload() reloads the current page. All accept waitUntil. Useful to test state persistence.
Load state
await page.waitForLoadState("load");
await page.waitForLoadState("domcontentloaded");
await page.waitForLoadState("networkidle");load waits for resources (images, CSS). domcontentloaded waits for the parsed DOM. networkidle waits 500ms with no network requests. The strictest is networkidle but it can be slow.
Geolocation and permissions
test.use({
geolocation: { latitude: 38.72, longitude: -9.14 },
permissions: ["geolocation"],
});
// in the test:
await page.goto("/map");
await expect(page.getByText("Lisbon")).toBeVisible();geolocation simulates GPS coordinates. permissions grants permissions without a dialog. Works with camera, microphone, notifications. Test location-based features.
Wait for URL
await page.waitForURL("**/dashboard");
await page.waitForURL(/.*\/users\/\d+/);
await page.waitForURL("**/login", { timeout: 5000 });waitForURL() waits until the URL matches. Accepts a glob (**) or regex. Essential after asynchronous redirects. A custom timeout for slow operations.
Frames and iframes
const frame = page.frameLocator("iframe#editor");
await frame.getByRole("button", { name: "Save" }).click();
// frame by name or URL:
const frame2 = page.frameLocator('iframe[name="payment"]');frameLocator() accesses content inside iframes. All operations work normally inside the frame. Chain for nested iframes. Essential for payment embeds.
Network emulation
test.use({
// simulate slow network:
contextOptions: {
// offline:
},
});
// offline:
await context.setOffline(true);
await page.reload();
await expect(page.getByText("No connection")).toBeVisible();
await context.setOffline(false);setOffline(true) simulates a connection loss. Test offline states and retry. setOffline(false) restores it. Essential for PWAs and apps with an offline mode.
New page / tab
const newPage = await context.newPage();
await newPage.goto("/");
// capture popup:
const [popup] = await Promise.all([
context.waitForEvent("page"),
page.getByRole("link", { name: "Open" }).click(),
]);
await popup.waitForLoadState();context.newPage() opens a new tab. waitForEvent("page") captures popups opened by clicks. Each page is independent. Popups share the same context (cookies).
Cookies and storage
// cookies:
await context.addCookies([{ name: "theme", value: "dark", url: "/" }]);
const cookies = await context.cookies();
// localStorage:
await page.evaluate(() => localStorage.setItem("token", "abc"));
const value = await page.evaluate(() => localStorage.getItem("token"));addCookies() injects cookies into the context. cookies() lists them all. evaluate() accesses localStorage/sessionStorage. Useful to prepare state without a manual login.
Multiple contexts
const userA = await browser.newContext();
const userB = await browser.newContext();
const pageA = await userA.newPage();
const pageB = await userB.newPage();
// independent sessions (separate cookies)
await pageA.goto("/login");
await pageB.goto("/login");browser.newContext() creates isolated sessions. Cookies and storage are independent. Ideal to test chat, multi-user permissions. Each context is like a fresh browser.
Advanced
Traces (debugging)
// config: record trace on failures
export default defineConfig({
use: {
trace: "on-first-retry",
// "on" | "off" | "retain-on-failure"
},
});
// manual:
await context.tracing.start({ screenshots: true, snapshots: true });
await context.tracing.stop({ path: "trace.zip" });trace records screenshots, DOM snapshots and network. on-first-retry records only on the retry. Open it with npx playwright show-trace trace.zip. Shows the full test timeline.
Downloads
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("button", { name: "Download" }).click(),
]);
const path = await download.path();
const name = download.suggestedFilename();
await download.saveAs("downloads/" + name);waitForEvent("download") captures the download. suggestedFilename() gives the original name. saveAs() saves it to disk. path() returns the temporary path. Combine it with Promise.all.
Clock and timers
await page.clock.install({ time: new Date("2025-01-01") });
await page.clock.pauseAt(new Date("2025-06-15"));
await page.clock.fastForward("30:00");
await page.clock.runFor(5000);
await page.clock.resume();clock.install() controls time in the browser. pauseAt() freezes it at a date. fastForward() advances without waiting. Test countdowns, expired sessions and scheduling.
Sharding (CI)
// split tests across 4 machines: npx playwright test --shard=1/4 npx playwright test --shard=2/4 npx playwright test --shard=3/4 npx playwright test --shard=4/4 // merge reports: npx playwright merge-reports ./reports
--shard splits the suite into parts for parallel CI. Each shard runs on a different machine. merge-reports combines the results. Reduces total time in CI/CD pipelines.
Screenshots
await page.screenshot({ path: "full.png", fullPage: true });
await locator.screenshot({ path: "component.png" });
// config: screenshot on failure
use: {
screenshot: "only-on-failure",
}screenshot() captures the page or an element. fullPage: true includes the full scroll. only-on-failure in the config saves it only on failure. Attached to the HTML report.
Visual comparison
await expect(page).toHaveScreenshot("homepage.png", {
maxDiffPixels: 100,
mask: [page.locator(".dynamic-data")],
});
// update baselines:
// npx playwright test --update-snapshotstoHaveScreenshot() compares pixel by pixel. mask hides dynamic areas (dates, avatars). --update-snapshots regenerates baselines. Detects CSS regressions automatically.
Aria snapshots
await expect(page.getByRole("list")).toMatchAriaSnapshot(`
- listitem: Product A
- listitem: Product B
- listitem:
- text: Product C
- button: Remove
`);toMatchAriaSnapshot() checks the accessibility tree. More resilient than HTML. Ignores implementation details. Ideal to verify the semantic structure of the page.
Video
// config:
use: {
video: "on-first-retry",
// "on" | "off" | "retain-on-failure"
}
// manual:
const video = page.video();
await video.saveAs("test.webm");
await video.delete();video records the test run. on-first-retry records only on the retry (saves space). Files in test-results/. Useful for visual debugging of intermittent failures.
Accessibility (a11y)
import AxeBuilder from "@axe-core/playwright";
test("no accessibility errors", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});@axe-core/playwright integrates Axe with Playwright. analyze() checks WCAG automatically. violations lists the problems found. You can filter by tags (wcag2a, wcag2aa).
Error handling in tests
test("graceful failure", async ({ page }) => {
await page.goto("/");
// capture console errors:
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") errors.push(msg.text());
});
page.on("pageerror", (err) => errors.push(err.message));
await page.getByRole("button").click();
expect(errors).toHaveLength(0);
});page.on("console") captures browser logs. page.on("pageerror") captures JS exceptions. Check for zero errors after actions. Detects silent bugs that do not break the UI.
Dialogs (alert/confirm)
page.on("dialog", (dialog) => {
console.log(dialog.message());
dialog.accept(); // OK
// dialog.dismiss(); // Cancel
});
// prompt with a value:
page.on("dialog", (d) => d.accept("answer"));page.on("dialog") registers a handler for alerts. accept() clicks OK. dismiss() clicks Cancel. For prompts, accept(value) fills it in. Without a handler, dialogs are dismissed.
Component testing
// playwright-ct.config.ts
import { defineConfig } from "@playwright/experimental-ct-react";
// component test:
import { test, expect } from "@playwright/experimental-ct-react";
import { Button } from "./Button";
test("button renders", async ({ mount }) => {
const component = await mount(<Button label="Click" />);
await expect(component).toContainText("Click");
});experimental-ct-react tests isolated components. mount() renders without the full app. Supports React, Vue and Svelte. Faster than E2E to test UI components.
Custom reporting
// config:
reporter: [
["html", { open: "never" }],
["json", { outputFile: "results.json" }],
["junit", { outputFile: "results.xml" }],
["list"],
],reporter accepts multiple simultaneous formats. html generates an interactive visual report. json and junit for CI. list shows progress in the terminal.
CLI and Configuration
Run tests
npx playwright test npx playwright test tests/login.spec.ts npx playwright test --project=chromium npx playwright test -g "logs in"
The base command runs all tests. Specify a file to run only one. --project filters by browser. -g filters by test name (grep). Combine filters freely.
Reports
npx playwright show-report
npx playwright show-report --port=9000
// formats in the config:
reporter: [["html"], ["list"], ["json", { outputFile: "r.json" }]]show-report opens the HTML report in the browser. Shows tests, failures, traces and screenshots. --port changes the port. The report is self-contained (you can share the folder).
Filter and repeat
npx playwright test --grep "login|signup" npx playwright test --grep-invert "slow" npx playwright test --repeat-each=3 npx playwright test --max-failures=5
--grep filters by regex on the name. --grep-invert excludes. --repeat-each repeats each test N times (detects flaky). --max-failures stops after N failures.
UI mode
npx playwright test --ui
--ui opens an interactive graphical interface. Shows the timeline, DOM snapshots and network. Lets you run individual tests or filter. Watch mode reruns on file save. Essential for development.
View trace
npx playwright show-trace trace.zip npx playwright show-trace test-results/trace.zip
show-trace opens the Trace Viewer. Shows a timeline with screenshots, DOM, network and console. You can navigate step by step. Essential to debug failures in CI (where there is no visible browser).
Advanced codegen
npx playwright codegen --target=python npx playwright codegen --device="Pixel 5" npx playwright codegen --viewport-size=1920,1080 npx playwright codegen --save-storage=auth.json npx playwright codegen --load-storage=auth.json
--target generates in Python, Java, C#. --device emulates mobile. --save-storage saves cookies on close. --load-storage starts authenticated. Great for prototyping tests.
Debug mode
npx playwright test --debug npx playwright test login.spec.ts --debug
--debug opens the Playwright Inspector. A visible browser with step-by-step controls. Shows suggested selectors on hover. Lets you run actions one by one. Ideal to create and debug tests.
Workers and parallelism
npx playwright test --workers=4
npx playwright test --workers=50%
// config:
export default defineConfig({
workers: process.env.CI ? 4 : undefined,
fullyParallel: true,
});workers sets the parallel processes. fullyParallel: true parallelizes tests within files. More workers = faster (up to the CPU limit). In CI use 4, locally auto.
CI (GitHub Actions)
# .github/workflows/playwright.yml
- uses: actions/setup-node@v4
- run: npx playwright install --with-deps
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/Install browsers with --with-deps in CI. Run headless tests (default). Upload the report on failure for debugging. Playwright's init generates the workflow automatically.
Headed and slowMo
npx playwright test --headed
npx playwright test --headed --slow-mo=500
// config:
use: {
headless: false,
launchOptions: { slowMo: 1000 },
}--headed shows the browser during the run. --slow-mo delays actions (ms). Useful to observe what the test does. In CI use headless (default) for speed.
Timeouts
export default defineConfig({
timeout: 30000, // per test
expect: { timeout: 5000 }, // per assertion
use: {
actionTimeout: 10000, // per action
navigationTimeout: 15000,
},
});timeout is the total limit per test. expect.timeout for assertions. actionTimeout for clicks/fills. navigationTimeout for goto/waitForURL. Adjust according to the app.
Extensions and output
npx playwright test --output=test-results npx playwright test --config=staging.config.ts npx playwright test --list npx playwright test --last-failed
--output sets the artifacts folder. --config uses an alternative config (staging). --list lists tests without running them. --last-failed reruns only the ones that failed. Very useful day to day.
Rede e Mocking
Intercept requests
await page.route("**/api/**", (route) => {
console.log(route.request().url());
route.continue();
});page.route() intercepts requests matching the pattern. route.continue() lets it pass normally. Allows logging, modification or blocking. The pattern uses glob with **.
Modify a response
await page.route("**/api/config", async (route) => {
const response = await route.fetch();
const json = await response.json();
json.featureFlag = true;
route.fulfill({ json });
});route.fetch() makes the real request and captures the response. Modify the JSON and return it with fulfill(). Ideal to enable feature flags or change data without a full mock.
Mock an error
await page.route("**/api/data", (route) => {
route.fulfill({ status: 500, body: "Internal error" });
});
await page.goto("/dashboard");
await expect(page.getByText("Failed to load")).toBeVisible();Simulate HTTP errors to test handling. status: 500 simulates a server error. Check error messages in the UI. Test resilience without breaking the backend.
Mock a response
await page.route("**/api/users", (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
json: [{ id: 1, name: "Anna" }],
});
});route.fulfill() returns a mocked response. json serializes automatically. status sets the HTTP code. Removes the backend dependency in UI tests.
Wait for a response
const [response] = await Promise.all([
page.waitForResponse("**/api/login"),
page.getByRole("button", { name: "Sign in" }).click(),
]);
expect(response.status()).toBe(200);
const body = await response.json();waitForResponse() waits for a specific response. Combine it with Promise.all() and the action that triggers it. Check the status and body. Essential to test API flows.
HAR replay
// record:
await context.routeFromHAR("har/recording.har", {
update: true,
});
// replay:
await context.routeFromHAR("har/recording.har", {
notFound: "fallback",
});routeFromHAR() replays responses recorded in a HAR. update: true records new requests. notFound: "fallback" passes to the server if it does not exist. Realistic mock with no maintenance.
Block requests
await page.route("**/*.{png,jpg,jpeg,gif}", (route) => route.abort());
await page.route("**/analytics/**", (route) => route.abort());
await page.route("**/*.css", (route) => route.abort());route.abort() cancels the request. Blocking images speeds up tests. Blocking analytics avoids noise. Accepts glob patterns with extensions. Useful for feature-focused tests.
Wait for a request
const [request] = await Promise.all([
page.waitForRequest("**/api/checkout"),
buyButton.click(),
]);
expect(request.method()).toBe("POST");
const postData = request.postDataJSON();
expect(postData.total).toBe(49.99);waitForRequest() captures the sent request. method() checks the HTTP verb. postDataJSON() parses the body. Validate that the frontend sends correct data.
Direct API testing
test("API creates a user", async ({ request }) => {
const response = await request.post("/api/users", {
data: { name: "Anna", email: "ana@site.com" },
});
expect(response.ok()).toBeTruthy();
const user = await response.json();
expect(user.id).toBeDefined();
});The request fixture makes HTTP calls without a browser. data sends JSON in the body. Ideal for test setup or testing pure APIs. Faster than tests with UI.
Modify a request
await page.route("**/api/**", (route) => {
route.continue({
headers: {
...route.request().headers(),
"X-Custom": "test",
},
});
});route.continue() with options modifies the request. You can change headers, method, postData. Useful to inject tokens or simulate conditions. The request goes to the real server.
Mock with delay
await page.route("**/api/slow", async (route) => {
await new Promise((r) => setTimeout(r, 3000));
route.fulfill({ json: { data: "ok" } });
});
// test the loading state:
await expect(spinner).toBeVisible();
await expect(spinner).toBeHidden({ timeout: 5000 });Add an artificial delay to test loading states. setTimeout simulates latency. Check that spinners appear and disappear. Tests UX under slow conditions.
Remove routes
const handler = (route) => route.abort();
await page.route("**/analytics/**", handler);
// later, remove it:
await page.unroute("**/analytics/**", handler);
// remove all:
await page.unrouteAll();unroute() removes a specific interceptor. unrouteAll() removes them all. Useful when a mock is only needed for part of the test. Avoids interference between tests.
Authentication and State
Login via UI
async function login(page: Page) {
await page.goto("/login");
await page.getByLabel("Email").fill("admin@site.com");
await page.getByLabel("Password").fill("password");
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL("/dashboard");
}A reusable helper function for login. waitForURL() confirms success. It can be used in beforeEach or a fixture. A simple approach but slow if repeated many times.
Multiple users
// auth/user.setup.ts → user.json
// auth/admin.setup.ts → admin.json
test.use({ storageState: "auth/admin.json" });
test("admin access", async ({ page }) => {
await page.goto("/admin");
await expect(page.getByText("Dashboard")).toBeVisible();
});Generate one storageState per user type. Each file has one role's cookies. Admin tests use admin.json, user tests use user.json. Clean and scalable.
Permissions and roles
test.describe("Admin", () => {
test.use({ storageState: "auth/admin.json" });
test("accesses /admin", async ({ page }) => {
await page.goto("/admin");
await expect(page.getByRole("heading")).toHaveText("Administration");
});
});
test.describe("User", () => {
test.use({ storageState: "auth/user.json" });
test("cannot access /admin", async ({ page }) => {
await page.goto("/admin");
await expect(page).toHaveURL("/");
});
});Test permissions with different auth states. Admin gets access, the user is redirected. test.use() per group sets the storageState. Complete RBAC coverage.
Save state (storageState)
// global-setup.ts: save the session
const browser = await chromium.launch();
const page = await browser.newPage();
await login(page);
await page.context().storageState({ path: "auth.json" });
await browser.close();storageState() saves cookies and localStorage to JSON. Run it once in the global setup. The auth.json file is reused by all tests. Avoids repeated logins.
Login via API (fast)
// setup: login without UI
test("authenticate", async ({ request }) => {
const response = await request.post("/api/login", {
data: { email: "admin@site.com", password: "pass" },
});
const { token } = await response.json();
// save token in storageState
});Login via request is much faster than via UI. It does not open a browser. Ideal to generate tokens in the setup. Combine with storageState to inject cookies.
Tokens and headers
// inject a token into all requests:
await context.route("**/api/**", (route) => {
route.continue({
headers: {
...route.request().headers(),
Authorization: `Bearer ${token}`,
},
});
});Intercept API requests to inject Authorization. Useful when the token is not in cookies. route.continue() with modified headers. An alternative to storageState for token-based APIs.
Reuse authentication
// playwright.config.ts:
export default defineConfig({
use: {
storageState: "auth.json",
},
});
// or per file:
test.use({ storageState: "auth.json" });storageState in the config loads cookies/localStorage. All tests start authenticated. No login in each test — much faster. The file is generated in the global setup.
Logout and invalid session
test("logout redirects", async ({ page }) => {
await page.goto("/dashboard");
await page.getByRole("button", { name: "Log out" }).click();
await expect(page).toHaveURL("/login");
});
test("expired session", async ({ page }) => {
await context.clearCookies();
await page.goto("/dashboard");
await expect(page).toHaveURL("/login");
});clearCookies() simulates an expired session. Check redirects to login. Test that protected areas are not accessible. Essential for application security.
2FA and verification
test("login with 2FA", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("user@site.com");
await page.getByLabel("Password").fill("pass");
await page.getByRole("button").click();
await page.getByLabel("Code").fill("123456");
await page.getByRole("button", { name: "Verify" }).click();
await expect(page).toHaveURL("/dashboard");
});Test the full two-factor flow. Fill the TOTP code (mock or real). Check the redirect after verification. In CI, use fixed codes or an API to generate TOTP.
Setup project (dependencies)
export default defineConfig({
projects: [
{ name: "setup", testMatch: /.*\.setup\.ts/ },
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
storageState: "auth.json",
},
dependencies: ["setup"],
},
],
});dependencies ensures setup runs first. The setup project generates auth.json. The other projects use the saved state. Playwright's official pattern for auth.
OAuth and SSO
// mock the OAuth provider:
await page.route("**/oauth/authorize**", (route) => {
const url = new URL(route.request().url());
const redirect = url.searchParams.get("redirect_uri");
route.fulfill({
status: 302,
headers: { Location: `${redirect}?code=test-code` },
});
});Mocking the OAuth redirect avoids the real provider. Intercept /oauth/authorize and return a redirect with a code. The flow continues the if it were real. Tests integration without external dependencies.
Session isolation
// each test has a clean context by default:
test("test A", async ({ page }) => {
// empty cookies, no storage
});
// share context between tests (careful):
test.describe("sequence", () => {
test.describe.configure({ mode: "serial" });
let context: BrowserContext;
test.beforeAll(async ({ browser }) => {
context = await browser.newContext();
});
});By default each test is isolated (clean context). For sequences with shared state use serial + a manual context. Prefer isolation — it avoids flaky tests and coupling.