Playwright has revolutionized web testing since its release in 2020. Built by Microsoft's team (the same engineers who created Puppeteer at Google), Playwright offers a modern, reliable approach to browser automation that solves many pain points of traditional testing tools.
This complete guide will take you from Playwright basics to advanced techniques, helping you build a robust test automation suite.
What is Playwright?#
Playwright is an open-source Node.js library that enables reliable end-to-end testing for modern web applications. It provides a single API (application programming interface) to automate Chromium, Firefox, and WebKit browsers, making cross-browser testing straightforward.
Why Playwright?#
Traditional testing tools (like Selenium) were built for a different era of web development. They struggle with:
- Dynamic content loading
- Single-page applications (SPAs)
- Complex JavaScript frameworks
- Flaky tests due to timing issues
Playwright solves these problems with:
- Auto-wait mechanism (no more
sleep()calls) - Network interception and mocking
- Multiple browser contexts in parallel
- Built-in test runner with powerful assertions
- Trace viewer for debugging
Getting Started#
Installation#
The fastest way to get started is using the init command:
npm init playwright@latest
npm init playwright@latest
This interactive setup will:
- Install Playwright and browsers
- Create example tests
- Set up
playwright.config.ts - Add npm scripts to
package.json
Manual installation:
npm install -D @playwright/testnpx playwright install
npm install -D @playwright/testnpx playwright install
Project Structure#
After initialization, you'll have:
my-project/├── tests/│ └── example.spec.ts├── playwright.config.ts├── package.json└── node_modules/
my-project/├── tests/│ └── example.spec.ts├── playwright.config.ts├── package.json└── node_modules/
Your First Test#
Create tests/login.spec.ts:
import { test, expect } from "@playwright/test";test("user can log in", async ({ page }) => {// Navigate to login pageawait page.goto("https://example.com/login");// Fill in credentials (use env vars for secrets)await page.getByTestId("email").fill(process.env.TEST_EMAIL ?? "");await page.getByTestId("password").fill(process.env.TEST_PASSWORD ?? "");// Click login buttonawait page.getByTestId("submit").click();// Verify successful loginawait expect(page).toHaveURL("/dashboard");await expect(page.getByTestId("welcome-message")).toContainText("Welcome back",);});
import { test, expect } from "@playwright/test";test("user can log in", async ({ page }) => {// Navigate to login pageawait page.goto("https://example.com/login");// Fill in credentials (use env vars for secrets)await page.getByTestId("email").fill(process.env.TEST_EMAIL ?? "");await page.getByTestId("password").fill(process.env.TEST_PASSWORD ?? "");// Click login buttonawait page.getByTestId("submit").click();// Verify successful loginawait expect(page).toHaveURL("/dashboard");await expect(page.getByTestId("welcome-message")).toContainText("Welcome back",);});
Running Tests#
# Run all testsnpx playwright test# Run in headed mode (see the browser)npx playwright test --headed# Run specific test filenpx playwright test tests/login.spec.ts# Run in debug modenpx playwright test --debug# Run in UI mode (interactive)npx playwright test --ui
# Run all testsnpx playwright test# Run in headed mode (see the browser)npx playwright test --headed# Run specific test filenpx playwright test tests/login.spec.ts# Run in debug modenpx playwright test --debug# Run in UI mode (interactive)npx playwright test --ui
Core Concepts#
1. Browser Contexts#
Browser contexts are isolated browser sessions. They're like incognito windows, each has its own cookies, localStorage, and cache.
import { chromium } from "@playwright/test";const browser = await chromium.launch();// Create two isolated contextsconst context1 = await browser.newContext();const context2 = await browser.newContext();// Each context has its own pagesconst page1 = await context1.newPage();const page2 = await context2.newPage();// They don't share stateawait page1.goto("https://example.com/login");await page2.goto("https://example.com/login");// Both see the login page (not logged in)
import { chromium } from "@playwright/test";const browser = await chromium.launch();// Create two isolated contextsconst context1 = await browser.newContext();const context2 = await browser.newContext();// Each context has its own pagesconst page1 = await context1.newPage();const page2 = await context2.newPage();// They don't share stateawait page1.goto("https://example.com/login");await page2.goto("https://example.com/login");// Both see the login page (not logged in)
Why use contexts?
- Run tests in parallel without interference
- Test multi-user scenarios
- Isolate test data
2. Selectors#
Playwright supports multiple selector strategies. Prefer getByTestId for stability:
// Recommended: data-testid via getByTestIdawait page.getByTestId("submit-btn").click();// Alternative when test IDs aren't availableawait page.getByRole("button", { name: "Submit" }).click();
// Recommended: data-testid via getByTestIdawait page.getByTestId("submit-btn").click();// Alternative when test IDs aren't availableawait page.getByRole("button", { name: "Submit" }).click();
Best practice: Use data-testid attributes and getByTestId:
<button data-testid="submit-btn">Submit</button>
<button data-testid="submit-btn">Submit</button>
await page.getByTestId("submit-btn").click();
await page.getByTestId("submit-btn").click();
3. Auto-Waiting#
Playwright automatically waits for elements to be:
- Attached to DOM (document object model)
- Visible
- Stable (not animating)
- Enabled
- Editable (for input fields)
// No manual waits needed!await page.click("button"); // Waits for button to be clickable// Old way (Selenium):await driver.wait(until.elementLocated(By.css("button")));await driver.wait(until.elementIsVisible(element));await driver.wait(until.elementIsEnabled(element));await element.click();
// No manual waits needed!await page.click("button"); // Waits for button to be clickable// Old way (Selenium):await driver.wait(until.elementLocated(By.css("button")));await driver.wait(until.elementIsVisible(element));await driver.wait(until.elementIsEnabled(element));await element.click();
Custom waits when needed:
// Wait for specific stateawait page.waitForLoadState("networkidle");// Wait for selectorawait page.waitForSelector(".loading-spinner", { state: "hidden" });// Wait for functionawait page.waitForFunction(() => window.dataLoaded === true);// Wait for responseawait page.waitForResponse((response) =>response.url().includes("/api/users") && response.status() === 200,);
// Wait for specific stateawait page.waitForLoadState("networkidle");// Wait for selectorawait page.waitForSelector(".loading-spinner", { state: "hidden" });// Wait for functionawait page.waitForFunction(() => window.dataLoaded === true);// Wait for responseawait page.waitForResponse((response) =>response.url().includes("/api/users") && response.status() === 200,);
4. Assertions#
Playwright includes powerful assertions via expect:
import { expect } from "@playwright/test";// Page assertionsawait expect(page).toHaveURL("/dashboard");await expect(page).toHaveTitle(/Dashboard/);// Element assertionsawait expect(page.locator(".message")).toBeVisible();await expect(page.locator(".message")).toContainText("Success");await expect(page.locator(".message")).toHaveClass(/success/);// Count assertionsawait expect(page.locator(".item")).toHaveCount(5);// Custom assertionsawait expect(page.locator(".price")).toHaveText("$19.99");
import { expect } from "@playwright/test";// Page assertionsawait expect(page).toHaveURL("/dashboard");await expect(page).toHaveTitle(/Dashboard/);// Element assertionsawait expect(page.locator(".message")).toBeVisible();await expect(page.locator(".message")).toContainText("Success");await expect(page.locator(".message")).toHaveClass(/success/);// Count assertionsawait expect(page.locator(".item")).toHaveCount(5);// Custom assertionsawait expect(page.locator(".price")).toHaveText("$19.99");
Advanced Features#
1. Network Interception#
Mock API responses for testing edge cases:
test("handles API error gracefully", async ({ page }) => {// Intercept API call and return errorawait page.route("**/api/users", (route) => {route.fulfill({status: 500,contentType: "application/json",body: JSON.stringify({ error: "Internal Server Error" }),});});await page.goto("/users");// Verify error handlingawait expect(page.locator(".error-message")).toBeVisible();});
test("handles API error gracefully", async ({ page }) => {// Intercept API call and return errorawait page.route("**/api/users", (route) => {route.fulfill({status: 500,contentType: "application/json",body: JSON.stringify({ error: "Internal Server Error" }),});});await page.goto("/users");// Verify error handlingawait expect(page.locator(".error-message")).toBeVisible();});
Modify requests:
await page.route("**/api/**", (route) => {const headers = route.request().headers();headers["Authorization"] = "Bearer fake-token";route.continue({ headers });});
await page.route("**/api/**", (route) => {const headers = route.request().headers();headers["Authorization"] = "Bearer fake-token";route.continue({ headers });});
Block resources:
// Block images and CSS for faster testsawait page.route("**/*.{png,jpg,jpeg,css}", (route) => route.abort());
// Block images and CSS for faster testsawait page.route("**/*.{png,jpg,jpeg,css}", (route) => route.abort());
2. Parallel Execution#
Run tests in parallel for speed:
// playwright.config.tsexport default {workers: 4, // Run 4 tests in parallelfullyParallel: true,};
// playwright.config.tsexport default {workers: 4, // Run 4 tests in parallelfullyParallel: true,};
Control parallelization:
// Run tests in this file seriallytest.describe.configure({ mode: "serial" });test("test 1", async ({ page }) => {/* ... */});test("test 2", async ({ page }) => {/* ... */});
// Run tests in this file seriallytest.describe.configure({ mode: "serial" });test("test 1", async ({ page }) => {/* ... */});test("test 2", async ({ page }) => {/* ... */});
3. Fixtures#
Fixtures provide reusable setup and teardown:
import { test as base } from "@playwright/test";// Extend base test with custom fixtureconst test = base.extend({authenticatedPage: async ({ page }, use) => {// Setup: Log in (use env vars for secrets)await page.goto("/login");await page.getByTestId("email").fill(process.env.TEST_EMAIL ?? "");await page.getByTestId("password").fill(process.env.TEST_PASSWORD ?? "");await page.getByTestId("submit").click();await page.waitForURL("/dashboard");// Provide authenticated page to testawait use(page);// Teardown: Log outawait page.getByTestId("logout").click();},});// Use the fixturetest("can create project", async ({ authenticatedPage }) => {await authenticatedPage.getByTestId("new-project").click();// Test continues with authenticated user});
import { test as base } from "@playwright/test";// Extend base test with custom fixtureconst test = base.extend({authenticatedPage: async ({ page }, use) => {// Setup: Log in (use env vars for secrets)await page.goto("/login");await page.getByTestId("email").fill(process.env.TEST_EMAIL ?? "");await page.getByTestId("password").fill(process.env.TEST_PASSWORD ?? "");await page.getByTestId("submit").click();await page.waitForURL("/dashboard");// Provide authenticated page to testawait use(page);// Teardown: Log outawait page.getByTestId("logout").click();},});// Use the fixturetest("can create project", async ({ authenticatedPage }) => {await authenticatedPage.getByTestId("new-project").click();// Test continues with authenticated user});
4. Visual Testing#
Capture and compare screenshots:
test("homepage looks correct", async ({ page }) => {await page.goto("/");// Take screenshot and compare with baselineawait expect(page).toHaveScreenshot("homepage.png");});
test("homepage looks correct", async ({ page }) => {await page.goto("/");// Take screenshot and compare with baselineawait expect(page).toHaveScreenshot("homepage.png");});
First run: Creates baseline screenshot
Subsequent runs: Compares against baseline
Update baselines:
npx playwright test --update-snapshots
npx playwright test --update-snapshots
5. Mobile Emulation#
Test responsive designs:
import { devices } from "@playwright/test";const iPhone = devices["iPhone 13"];test("mobile navigation works", async ({ browser }) => {const context = await browser.newContext({...iPhone,});const page = await context.newPage();await page.goto("/");await page.getByTestId("mobile-menu").click();await expect(page.locator(".nav-menu")).toBeVisible();});
import { devices } from "@playwright/test";const iPhone = devices["iPhone 13"];test("mobile navigation works", async ({ browser }) => {const context = await browser.newContext({...iPhone,});const page = await context.newPage();await page.goto("/");await page.getByTestId("mobile-menu").click();await expect(page.locator(".nav-menu")).toBeVisible();});
6. Trace Viewer#
Debug failed tests with trace viewer:
// playwright.config.tsexport default {use: {trace: "on-first-retry", // Record trace on first retry},};
// playwright.config.tsexport default {use: {trace: "on-first-retry", // Record trace on first retry},};
View trace:
npx playwright show-trace trace.zip
npx playwright show-trace trace.zip
The trace viewer shows:
- Screenshots at each step
- Network requests
- Console logs
- DOM snapshots
- Timing information
7. API Testing#
Test APIs alongside UI tests:
test("API returns user data", async ({ request }) => {const response = await request.get("https://api.example.com/users/1");expect(response.status()).toBe(200);const user = await response.json();expect(user).toHaveProperty("id", 1);expect(user).toHaveProperty("email");});
test("API returns user data", async ({ request }) => {const response = await request.get("https://api.example.com/users/1");expect(response.status()).toBe(200);const user = await response.json();expect(user).toHaveProperty("id", 1);expect(user).toHaveProperty("email");});
Combine UI and API testing:
test("create user via API, verify in UI", async ({ request, page }) => {// Create user via APIconst response = await request.post("/api/users", {});const user = await response.json();// Verify in UIawait page.goto(`/users/${user.id}`);await expect(page.locator("h1")).toContainText("Test User");});
test("create user via API, verify in UI", async ({ request, page }) => {// Create user via APIconst response = await request.post("/api/users", {});const user = await response.json();// Verify in UIawait page.goto(`/users/${user.id}`);await expect(page.locator("h1")).toContainText("Test User");});
Configuration#
playwright.config.ts#
import { defineConfig, devices } from "@playwright/test";export default defineConfig({// Test directorytestDir: "./tests",// Timeout for each testtimeout: 30000,// Retry failed testsretries: process.env.CI ? 2 : 0,// Run tests in parallelworkers: process.env.CI ? 1 : 4,// Reporterreporter: [["html"],["json", { outputFile: "test-results.json" }],["junit", { outputFile: "test-results.xml" }],],// Shared settings for all testsuse: {// Base URL (web address)baseURL: "http://localhost:3000",// Browser optionsheadless: true,viewport: { width: 1280, height: 720 },// Artifactsscreenshot: "only-on-failure",video: "retain-on-failure",trace: "on-first-retry",// TimeoutsactionTimeout: 10000,navigationTimeout: 15000,},// Projects for different browsersprojects: [{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"] },},{name: "mobile-safari",use: { ...devices["iPhone 13"] },},],// Web serverwebServer: {command: "npm run start",port: 3000,reuseExistingServer: !process.env.CI,},});
import { defineConfig, devices } from "@playwright/test";export default defineConfig({// Test directorytestDir: "./tests",// Timeout for each testtimeout: 30000,// Retry failed testsretries: process.env.CI ? 2 : 0,// Run tests in parallelworkers: process.env.CI ? 1 : 4,// Reporterreporter: [["html"],["json", { outputFile: "test-results.json" }],["junit", { outputFile: "test-results.xml" }],],// Shared settings for all testsuse: {// Base URL (web address)baseURL: "http://localhost:3000",// Browser optionsheadless: true,viewport: { width: 1280, height: 720 },// Artifactsscreenshot: "only-on-failure",video: "retain-on-failure",trace: "on-first-retry",// TimeoutsactionTimeout: 10000,navigationTimeout: 15000,},// Projects for different browsersprojects: [{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"] },},{name: "mobile-safari",use: { ...devices["iPhone 13"] },},],// Web serverwebServer: {command: "npm run start",port: 3000,reuseExistingServer: !process.env.CI,},});
Best Practices#
1. Use Data Attributes for Selectors#
<!-- Good --><button data-testid="submit-btn">Submit</button><!-- Avoid --><button class="btn btn-primary submit-button-v2">Submit</button>
<!-- Good --><button data-testid="submit-btn">Submit</button><!-- Avoid --><button class="btn btn-primary submit-button-v2">Submit</button>
// Use getByTestIdawait page.getByTestId("submit-btn").click();// Fragile selector (breaks when CSS changes)await page.click(".btn.btn-primary.submit-button-v2");
// Use getByTestIdawait page.getByTestId("submit-btn").click();// Fragile selector (breaks when CSS changes)await page.click(".btn.btn-primary.submit-button-v2");
2. Leverage Auto-Waiting#
// Good: Let Playwright handle waitingawait page.click("button");// Bad: Manual waitsawait page.waitForTimeout(1000);await page.click("button");
// Good: Let Playwright handle waitingawait page.click("button");// Bad: Manual waitsawait page.waitForTimeout(1000);await page.click("button");
3. Test in Isolation#
// Good: Each test is independenttest("test 1", async ({ page }) => {await page.goto("/");// Test logic});test("test 2", async ({ page }) => {await page.goto("/");// Test logic});// Bad: Tests depend on each othertest("test 1", async ({ page }) => {await page.goto("/");await page.click("button");});test("test 2", async ({ page }) => {// Assumes test 1 ran firstawait page.click("another-button");});
// Good: Each test is independenttest("test 1", async ({ page }) => {await page.goto("/");// Test logic});test("test 2", async ({ page }) => {await page.goto("/");// Test logic});// Bad: Tests depend on each othertest("test 1", async ({ page }) => {await page.goto("/");await page.click("button");});test("test 2", async ({ page }) => {// Assumes test 1 ran firstawait page.click("another-button");});
4. Use Meaningful Test Names#
// Goodtest("user can reset password via email link", async ({ page }) => {});// Badtest("test1", async ({ page }) => {});
// Goodtest("user can reset password via email link", async ({ page }) => {});// Badtest("test1", async ({ page }) => {});
5. Group Related Tests#
test.describe("Authentication", () => {test("user can log in", async ({ page }) => {});test("user can log out", async ({ page }) => {});test("user can reset password", async ({ page }) => {});});
test.describe("Authentication", () => {test("user can log in", async ({ page }) => {});test("user can log out", async ({ page }) => {});test("user can reset password", async ({ page }) => {});});
6. Use Environment Variables for Secrets#
// Goodconst password = process.env.TEST_PASSWORD;// Badconst password = "hardcoded-password";
// Goodconst password = process.env.TEST_PASSWORD;// Badconst password = "hardcoded-password";
7. Clean Up Test Data#
test("create user", async ({ page, request }) => {// Create userconst response = await request.post("/api/users", {});const userId = (await response.json()).id;// Test logicawait page.goto(`/users/${userId}`);// Cleanupawait request.delete(`/api/users/${userId}`);});
test("create user", async ({ page, request }) => {// Create userconst response = await request.post("/api/users", {});const userId = (await response.json()).id;// Test logicawait page.goto(`/users/${userId}`);// Cleanupawait request.delete(`/api/users/${userId}`);});
8. Reuse Auth with a Setup Project#
Instead of logging in in every test, use a dedicated setup project that saves an authenticated context to a JSON file and reuse it via storageState:
// auth.setup.tsimport { test as setup } from "@playwright/test";const AUTH_STATE = "playwright/.auth/user.json";setup("authenticate", async ({ page }) => {await page.goto("/login");await page.getByTestId("email").fill(process.env.E2E_EMAIL ?? "");await page.getByTestId("password").fill(process.env.E2E_PASSWORD ?? "");await page.getByTestId("submit").click();await page.waitForURL("/dashboard");// Save authenticated storage stateawait page.context().storageState({ path: AUTH_STATE });});
// auth.setup.tsimport { test as setup } from "@playwright/test";const AUTH_STATE = "playwright/.auth/user.json";setup("authenticate", async ({ page }) => {await page.goto("/login");await page.getByTestId("email").fill(process.env.E2E_EMAIL ?? "");await page.getByTestId("password").fill(process.env.E2E_PASSWORD ?? "");await page.getByTestId("submit").click();await page.waitForURL("/dashboard");// Save authenticated storage stateawait page.context().storageState({ path: AUTH_STATE });});
// playwright.config.tsimport { defineConfig, devices } from "@playwright/test";const AUTH_STATE = "playwright/.auth/user.json";export default defineConfig({projects: [{name: "setup",testMatch: /.*\\.setup\\.ts/,},{name: "chromium",use: {...devices["Desktop Chrome"],storageState: AUTH_STATE,},dependencies: ["setup"], // Ensure auth runs before main tests},],});
// playwright.config.tsimport { defineConfig, devices } from "@playwright/test";const AUTH_STATE = "playwright/.auth/user.json";export default defineConfig({projects: [{name: "setup",testMatch: /.*\\.setup\\.ts/,},{name: "chromium",use: {...devices["Desktop Chrome"],storageState: AUTH_STATE,},dependencies: ["setup"], // Ensure auth runs before main tests},],});
CI/CD (continuous integration/continuous deployment) integration#
GitHub Actions#
name: Playwright Testson: [push, pull_request]jobs:test:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v4- uses: actions/setup-node@v4with:node-version: 20- name: Install dependenciesrun: npm ci- name: Install Playwright Browsersrun: npx playwright install --with-deps- name: Run Playwright testsrun: npx playwright test- uses: actions/upload-artifact@v4if: always()with:name: playwright-reportpath: playwright-report/
name: Playwright Testson: [push, pull_request]jobs:test:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v4- uses: actions/setup-node@v4with:node-version: 20- name: Install dependenciesrun: npm ci- name: Install Playwright Browsersrun: npx playwright install --with-deps- name: Run Playwright testsrun: npx playwright test- uses: actions/upload-artifact@v4if: always()with:name: playwright-reportpath: playwright-report/
GitLab CI#
test:image: mcr.microsoft.com/playwright:v1.56.0-jammyscript:- npm ci- npx playwright testartifacts:when: alwayspaths:- playwright-report/
test:image: mcr.microsoft.com/playwright:v1.56.0-jammyscript:- npm ci- npx playwright testartifacts:when: alwayspaths:- playwright-report/
Troubleshooting#
Flaky Tests#
Problem: Tests pass sometimes, fail other times.
Solutions:
- Use auto-waiting (avoid
waitForTimeout) - Wait for network idle:
await page.waitForLoadState('networkidle') - Use
toPassfor retries:
await expect(async () => {const response = await page.request.get("/api/status");expect(response.status()).toBe(200);}).toPass();
await expect(async () => {const response = await page.request.get("/api/status");expect(response.status()).toBe(200);}).toPass();
Slow Tests#
Problem: Tests take too long.
Solutions:
- Run in parallel:
workers: 4 - Block unnecessary resources:
await page.route('**/*.{png,jpg}', route => route.abort()) - Use API for setup: Create test data via API instead of UI
Debugging#
# Run in debug modenpx playwright test --debug# Run in headed modenpx playwright test --headed# Run in UI modenpx playwright test --ui# View tracenpx playwright show-trace trace.zip
# Run in debug modenpx playwright test --debug# Run in headed modenpx playwright test --headed# Run in UI modenpx playwright test --ui# View tracenpx playwright show-trace trace.zip
Conclusion#
Playwright's auto-wait mechanism, cross-browser support, and trace viewer make it a strong choice for modern web testing. Use data-testid for stable selectors and run tests in parallel for speed.
Read What is Playwright? for a gentler intro, or try ObserveOne free to turn any URL into a self-healing Playwright suite with Autopilot.