PlaywrightIntermediate

Playwright: The Complete Guide for Modern Web Testing

Master Playwright with this comprehensive guide. Learn installation, best practices, advanced features, and how to build reliable test automation.

ObserveOne Team
11 min read

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

This interactive setup will:

  1. Install Playwright and browsers
  2. Create example tests
  3. Set up playwright.config.ts
  4. Add npm scripts to package.json

Manual installation:

npm install -D @playwright/test
npx playwright install

Project Structure#

After initialization, you'll have:

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 page
await 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 button
await page.getByTestId("submit").click();
// Verify successful login
await expect(page).toHaveURL("/dashboard");
await expect(page.getByTestId("welcome-message")).toContainText(
"Welcome back",
);
});

Running Tests#

# Run all tests
npx playwright test
# Run in headed mode (see the browser)
npx playwright test --headed
# Run specific test file
npx playwright test tests/login.spec.ts
# Run in debug mode
npx 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 contexts
const context1 = await browser.newContext();
const context2 = await browser.newContext();
// Each context has its own pages
const page1 = await context1.newPage();
const page2 = await context2.newPage();
// They don't share state
await 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 getByTestId
await page.getByTestId("submit-btn").click();
// Alternative when test IDs aren't available
await page.getByRole("button", { name: "Submit" }).click();

Best practice: Use data-testid attributes and getByTestId:

<button data-testid="submit-btn">Submit</button>
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();

Custom waits when needed:

// Wait for specific state
await page.waitForLoadState("networkidle");
// Wait for selector
await page.waitForSelector(".loading-spinner", { state: "hidden" });
// Wait for function
await page.waitForFunction(() => window.dataLoaded === true);
// Wait for response
await 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 assertions
await expect(page).toHaveURL("/dashboard");
await expect(page).toHaveTitle(/Dashboard/);
// Element assertions
await expect(page.locator(".message")).toBeVisible();
await expect(page.locator(".message")).toContainText("Success");
await expect(page.locator(".message")).toHaveClass(/success/);
await expect(page.locator("input")).toHaveValue("[email protected]");
// Count assertions
await expect(page.locator(".item")).toHaveCount(5);
// Custom assertions
await 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 error
await 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 handling
await 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 });
});

Block resources:

// Block images and CSS for faster tests
await page.route("**/*.{png,jpg,jpeg,css}", (route) => route.abort());

2. Parallel Execution#

Run tests in parallel for speed:

// playwright.config.ts
export default {
workers: 4, // Run 4 tests in parallel
fullyParallel: true,
};

Control parallelization:

// Run tests in this file serially
test.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 fixture
const 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 test
await use(page);
// Teardown: Log out
await page.getByTestId("logout").click();
},
});
// Use the fixture
test("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 baseline
await expect(page).toHaveScreenshot("homepage.png");
});

First run: Creates baseline screenshot
Subsequent runs: Compares against baseline

Update baselines:

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();
});

6. Trace Viewer#

Debug failed tests with trace viewer:

// playwright.config.ts
export default {
use: {
trace: "on-first-retry", // Record trace on first retry
},
};

View trace:

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");
});

Combine UI and API testing:

test("create user via API, verify in UI", async ({ request, page }) => {
// Create user via API
const response = await request.post("/api/users", {
data: { name: "Test User", email: "[email protected]" },
});
const user = await response.json();
// Verify in UI
await 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 directory
testDir: "./tests",
// Timeout for each test
timeout: 30000,
// Retry failed tests
retries: process.env.CI ? 2 : 0,
// Run tests in parallel
workers: process.env.CI ? 1 : 4,
// Reporter
reporter: [
["html"],
["json", { outputFile: "test-results.json" }],
["junit", { outputFile: "test-results.xml" }],
],
// Shared settings for all tests
use: {
// Base URL (web address)
baseURL: "http://localhost:3000",
// Browser options
headless: true,
viewport: { width: 1280, height: 720 },
// Artifacts
screenshot: "only-on-failure",
video: "retain-on-failure",
trace: "on-first-retry",
// Timeouts
actionTimeout: 10000,
navigationTimeout: 15000,
},
// Projects for different browsers
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"] },
},
{
name: "mobile-safari",
use: { ...devices["iPhone 13"] },
},
],
// Web server
webServer: {
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>
// Use getByTestId
await 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 waiting
await page.click("button");
// Bad: Manual waits
await page.waitForTimeout(1000);
await page.click("button");

3. Test in Isolation#

// Good: Each test is independent
test("test 1", async ({ page }) => {
await page.goto("/");
// Test logic
});
test("test 2", async ({ page }) => {
await page.goto("/");
// Test logic
});
// Bad: Tests depend on each other
test("test 1", async ({ page }) => {
await page.goto("/");
await page.click("button");
});
test("test 2", async ({ page }) => {
// Assumes test 1 ran first
await page.click("another-button");
});

4. Use Meaningful Test Names#

// Good
test("user can reset password via email link", async ({ page }) => {});
// Bad
test("test1", 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#

// Good
const password = process.env.TEST_PASSWORD;
// Bad
const password = "hardcoded-password";

7. Clean Up Test Data#

test("create user", async ({ page, request }) => {
// Create user
const response = await request.post("/api/users", {
data: { email: "[email protected]" },
});
const userId = (await response.json()).id;
// Test logic
await page.goto(`/users/${userId}`);
// Cleanup
await 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.ts
import { 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 state
await page.context().storageState({ path: AUTH_STATE });
});
// playwright.config.ts
import { 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 Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/

GitLab CI#

test:
image: mcr.microsoft.com/playwright:v1.56.0-jammy
script:
- npm ci
- npx playwright test
artifacts:
when: always
paths:
- playwright-report/

Troubleshooting#

Flaky Tests#

Problem: Tests pass sometimes, fail other times.

Solutions:

  1. Use auto-waiting (avoid waitForTimeout)
  2. Wait for network idle: await page.waitForLoadState('networkidle')
  3. Use toPass for retries:
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:

  1. Run in parallel: workers: 4
  2. Block unnecessary resources: await page.route('**/*.{png,jpg}', route => route.abort())
  3. Use API for setup: Create test data via API instead of UI

Debugging#

# Run in debug mode
npx playwright test --debug
# Run in headed mode
npx playwright test --headed
# Run in UI mode
npx playwright test --ui
# View trace
npx 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.

Frequently Asked Questions

A browser context is an isolated session with its own cookies, localStorage, and cache, similar to an incognito window. A page is a single tab living inside a context. One context can hold multiple pages, and separate contexts share no state, which keeps parallel tests from interfering.

Use a dedicated setup project that signs in once and saves the authenticated session with storageState to a JSON file. Other projects then load that file via storageState and declare a dependency on setup, so login runs before the main tests rather than inside each one.

Yes. Use page.route to intercept matching requests and call route.fulfill with a custom status, content type, and body, such as a 500 error. This lets you test how the interface handles failures, and you can also modify headers or abort requests to block resources.

Intermittent failures often come from manual waitForTimeout calls or unsettled network activity. Replace fixed timeouts with auto-waiting, wait for networkidle with waitForLoadState, and wrap flaky checks in expect(...).toPass() so they retry. The trace viewer helps pinpoint the failing step with screenshots and logs.

Weighing your options on this stack?

Drop into the head-to-head pages, or browse the alternatives we recommend.

Alternatives shortlists

Ready for AI-Powered Testing?

ObserveOne monitors your selectors 24/7 and automatically heals them when websites change. Never deal with broken tests again.

Start Free Trial