UI Automation
Playwright
Complete Guide

Playwright Testing: Complete Guide for Modern Web Automation

Parul Dhingra - Senior Quality Analyst
Parul Dhingra13+ Years ExperienceHire Me

Senior Quality Analyst

Updated: 1/23/2026

Playwright has rapidly become one of the most powerful tools for browser automation and end-to-end testing. Developed by Microsoft and released in 2020, it was created by the same team that originally built Puppeteer, bringing years of browser automation expertise to a new, more capable framework.

What makes Playwright stand out is its modern approach to common testing challenges. Instead of forcing you to sprinkle waits throughout your code, Playwright automatically waits for elements to be ready. Instead of testing one browser at a time, it runs tests across Chromium, Firefox, and WebKit simultaneously. These aren't just nice features - they fundamentally change how you write and maintain test suites.

Why Playwright?

Before diving into code, it's worth understanding what problems Playwright solves:

Cross-Browser Testing From Day One: Playwright supports Chromium, Firefox, and WebKit (Safari's engine) using a single API. You write tests once and run them everywhere. This matters because browser inconsistencies still cause real bugs in production.

Auto-Waiting Built In: The framework automatically waits for elements to be actionable before performing operations. No more sleep() statements or flaky custom wait logic.

Modern Web Support: Playwright handles shadow DOM, iframes, multiple tabs, file downloads, and network interception out of the box. Modern web applications use these features extensively.

Multiple Language Support: While JavaScript and TypeScript are the primary languages, Playwright also supports Python, Java, and .NET with official bindings.

Developer Experience: Tools like Playwright Inspector, Trace Viewer, and Codegen make debugging and test creation significantly easier.

Playwright downloads browser binaries during installation rather than relying on browsers installed on your system. This ensures consistent behavior across different machines and CI environments.

Installation and Setup

Getting started with Playwright is straightforward. You'll need Node.js 18 or later installed.

Creating a New Project

The recommended approach is using the init command:

npm init playwright@latest

This interactive setup will:

  • Create a new project or add Playwright to an existing one
  • Ask which language you prefer (TypeScript or JavaScript)
  • Set up your test directory structure
  • Optionally add a GitHub Actions workflow
  • Install browsers

If you prefer manual setup:

npm install -D @playwright/test
npx playwright install

The second command downloads the browser binaries - expect around 500MB of downloads for all three browsers.

TypeScript Configuration

Playwright works with TypeScript out of the box. The init command creates a playwright.config.ts file that TypeScript understands. If you're adding to an existing project, ensure your tsconfig.json includes:

{
  "compilerOptions": {
    "strict": true,
    "module": "ESNext",
    "moduleResolution": "Node"
  }
}

Project Structure

After initialization, your project will look like this:

your-project/
├── tests/
│   └── example.spec.ts
├── tests-examples/
│   └── demo-todo-app.spec.ts
├── playwright.config.ts
└── package.json

tests/: Your test files go here. Any file ending in .spec.ts or .test.ts is recognized as a test file.

playwright.config.ts: Central configuration for timeouts, browsers, base URLs, and more.

tests-examples/: Sample tests demonstrating Playwright features. Feel free to delete these once you understand them.

A more mature project structure might look like:

your-project/
├── tests/
│   ├── auth/
│   │   ├── login.spec.ts
│   │   └── signup.spec.ts
│   ├── dashboard/
│   │   └── widgets.spec.ts
│   └── checkout/
│       └── payment.spec.ts
├── pages/
│   ├── login.page.ts
│   └── dashboard.page.ts
├── fixtures/
│   └── test-data.ts
├── playwright.config.ts
└── package.json

Writing Your First Test

Let's write a test that actually does something useful. Here's a test for a search feature:

import { test, expect } from '@playwright/test'
 
test('search returns relevant results', async ({ page }) => {
  // Navigate to the page
  await page.goto('https://example-store.com')
 
  // Find the search input and type a query
  await page.getByRole('searchbox').fill('wireless headphones')
 
  // Click the search button
  await page.getByRole('button', { name: 'Search' }).click()
 
  // Verify results appear
  await expect(
    page.getByRole('heading', { name: 'Search Results' }),
  ).toBeVisible()
 
  // Check that results contain our search term
  const firstResult = page.locator('.product-card').first()
  await expect(firstResult).toContainText(/headphones/i)
})

Let's break down what's happening:

  1. test() defines a test case with a description and an async function
  2. page is a fixture automatically provided by Playwright representing a browser page
  3. getByRole() finds elements by their accessibility role - more on this in the locators section
  4. expect() makes assertions about the page state
  5. await is required because browser operations are asynchronous

Grouping Related Tests

Use test.describe() to organize tests:

import { test, expect } from '@playwright/test'
 
test.describe('User Authentication', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login')
  })
 
  test('successful login redirects to dashboard', async ({ page }) => {
    await page.getByLabel('Email').fill('user@example.com')
    await page.getByLabel('Password').fill('password123')
    await page.getByRole('button', { name: 'Sign In' }).click()
 
    await expect(page).toHaveURL('/dashboard')
  })
 
  test('invalid credentials show error message', async ({ page }) => {
    await page.getByLabel('Email').fill('wrong@example.com')
    await page.getByLabel('Password').fill('wrongpassword')
    await page.getByRole('button', { name: 'Sign In' }).click()
 
    await expect(page.getByRole('alert')).toContainText('Invalid credentials')
  })
})

Core Concepts

Page Object

The page object is your primary interface for interacting with the browser. It represents a single tab or page and provides methods for:

  • Navigation: goto(), goBack(), reload()
  • Finding elements: locator(), getByRole(), getByText()
  • Actions: click(), fill(), type(), press()
  • Evaluation: evaluate(), evaluateHandle()
  • State: title(), url(), content()

Browser Context

A browser context is like an incognito window - it has its own cookies, storage, and session state:

test('user sessions are isolated', async ({ browser }) => {
  // Create two isolated contexts
  const userOneContext = await browser.newContext()
  const userTwoContext = await browser.newContext()
 
  const pageOne = await userOneContext.newPage()
  const pageTwo = await userTwoContext.newPage()
 
  // Actions in pageOne don't affect pageTwo
  await pageOne.goto('/login')
  // ... login as user one
 
  await pageTwo.goto('/login')
  // pageTwo still sees the login page, not the dashboard
})

Locators

Locators are the heart of element selection in Playwright. Unlike raw selectors, locators:

  • Auto-wait for elements to appear
  • Auto-retry if elements become stale
  • Support chaining for complex selections
// Basic locator
const submitButton = page.locator('button[type="submit"]')
 
// Chained locator
const modalSubmit = page.locator('.modal').locator('button[type="submit"]')
 
// Filtered locator
const activeItem = page.locator('li').filter({ hasText: 'Active' })

Auto-Waiting Mechanism

Playwright's auto-waiting eliminates most timing issues. When you call an action method, Playwright automatically waits for:

  1. Element to be attached to the DOM
  2. Element to be visible (not hidden via CSS)
  3. Element to be stable (not animating)
  4. Element to be enabled (not disabled)
  5. Element to receive events (not obscured by other elements)
// This automatically waits for all conditions
await page.getByRole('button', { name: 'Submit' }).click()
 
// No need for this:
// await page.waitForSelector('button');
// await page.waitForFunction(() => !button.disabled);
// await page.click('button');
⚠️

Auto-waiting applies to action methods like click(), fill(), and check(). For assertions, use expect() with toBeVisible(), toHaveText(), etc., which have their own waiting mechanism.

Custom Waits When Needed

Sometimes you do need explicit waits:

// Wait for navigation to complete
await page.waitForURL('/dashboard')
 
// Wait for a specific network response
await page.waitForResponse(
  (response) =>
    response.url().includes('/api/data') && response.status() === 200,
)
 
// Wait for an element to disappear
await expect(page.locator('.loading-spinner')).toBeHidden()
 
// Wait for a condition
await page.waitForFunction(() => window.appReady === true)

Assertions and Expectations

Playwright Test includes powerful assertions via the expect() function. These assertions automatically retry until the condition is met or timeout:

// Visibility assertions
await expect(page.getByText('Welcome')).toBeVisible()
await expect(page.locator('.modal')).toBeHidden()
 
// Text assertions
await expect(page.locator('h1')).toHaveText('Dashboard')
await expect(page.locator('.message')).toContainText('Success')
 
// Attribute assertions
await expect(page.locator('input')).toHaveValue('test@example.com')
await expect(page.locator('button')).toBeEnabled()
await expect(page.locator('input')).toHaveAttribute(
  'placeholder',
  'Enter email',
)
 
// Count assertions
await expect(page.locator('.item')).toHaveCount(5)
 
// URL and title assertions
await expect(page).toHaveURL(/.*dashboard/)
await expect(page).toHaveTitle('My App - Dashboard')

Soft Assertions

Regular assertions stop test execution on failure. Soft assertions continue and report all failures at the end:

await expect.soft(page.locator('.header')).toHaveText('Welcome')
await expect.soft(page.locator('.user-name')).toHaveText('John')
await expect.soft(page.locator('.notifications')).toHaveCount(3)
// Test continues even if assertions fail

Running Tests

Command Line

# Run all tests
npx playwright test
 
# Run specific file
npx playwright test tests/login.spec.ts
 
# Run tests matching a pattern
npx playwright test -g "login"
 
# Run in headed mode (see the browser)
npx playwright test --headed
 
# Run in specific browser
npx playwright test --project=firefox
 
# Run with UI mode (interactive debugging)
npx playwright test --ui
 
# Debug a specific test
npx playwright test --debug

Parallel Execution

By default, Playwright runs test files in parallel. Configure parallelism in playwright.config.ts:

export default defineConfig({
  // Run tests in files in parallel
  fullyParallel: true,
 
  // Number of parallel workers
  workers: process.env.CI ? 1 : undefined,
})

Reporters

Generate different report formats:

# HTML report (default in CI)
npx playwright test --reporter=html
 
# Show report
npx playwright show-report
 
# Multiple reporters
npx playwright test --reporter=list,html

Configuration Options

The playwright.config.ts file controls global behavior:

import { defineConfig, devices } from '@playwright/test'
 
export default defineConfig({
  // Test directory
  testDir: './tests',
 
  // Maximum time per test
  timeout: 30 * 1000,
 
  // Assertion timeout
  expect: {
    timeout: 5000,
  },
 
  // Run all tests in parallel
  fullyParallel: true,
 
  // Fail the build on CI if test.only is present
  forbidOnly: !!process.env.CI,
 
  // Retry failed tests
  retries: process.env.CI ? 2 : 0,
 
  // Reporter configuration
  reporter: 'html',
 
  // Shared settings for all projects
  use: {
    // Base URL for navigation
    baseURL: 'http://localhost:3000',
 
    // Capture screenshot on failure
    screenshot: 'only-on-failure',
 
    // Record trace on failure
    trace: 'on-first-retry',
  },
 
  // Configure 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'] },
    },
  ],
 
  // Run local dev server before tests
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})

Best Practices

Use Semantic Locators

Prefer locators based on accessibility roles and user-visible attributes:

// Preferred - uses accessibility semantics
await page.getByRole('button', { name: 'Submit' }).click()
await page.getByLabel('Email address').fill('test@example.com')
await page.getByPlaceholder('Search...').fill('query')
 
// Acceptable - uses test IDs
await page.getByTestId('submit-button').click()
 
// Avoid - brittle and tied to implementation
await page.locator('.btn.btn-primary.submit-btn').click()
await page.locator('#form > div:nth-child(2) > button').click()

Keep Tests Independent

Each test should set up its own state and not depend on other tests:

// Good - self-contained
test('can update profile', async ({ page }) => {
  // Set up: create user and log in
  await createTestUser('test@example.com')
  await page.goto('/login')
  await login(page, 'test@example.com', 'password')
 
  // Test action
  await page.goto('/profile')
  await page.getByLabel('Name').fill('New Name')
  await page.getByRole('button', { name: 'Save' }).click()
 
  // Verify
  await expect(page.getByText('Profile updated')).toBeVisible()
})

Use Page Object Model for Complex Apps

For larger test suites, encapsulate page interactions:

// pages/login.page.ts
export class LoginPage {
  constructor(private page: Page) {}
 
  async goto() {
    await this.page.goto('/login')
  }
 
  async login(email: string, password: string) {
    await this.page.getByLabel('Email').fill(email)
    await this.page.getByLabel('Password').fill(password)
    await this.page.getByRole('button', { name: 'Sign In' }).click()
  }
}
 
// tests/auth.spec.ts
test('user can log in', async ({ page }) => {
  const loginPage = new LoginPage(page)
  await loginPage.goto()
  await loginPage.login('user@example.com', 'password')
  await expect(page).toHaveURL('/dashboard')
})

Handle Test Data Thoughtfully

Don't hard-code test data across multiple tests:

// fixtures/users.ts
export const testUsers = {
  admin: { email: 'admin@test.com', password: 'admin123' },
  regular: { email: 'user@test.com', password: 'user123' },
}
 
// tests/admin.spec.ts
import { testUsers } from '../fixtures/users'
 
test('admin can access settings', async ({ page }) => {
  await login(page, testUsers.admin.email, testUsers.admin.password)
  // ...
})

Playwright provides the foundation for reliable, maintainable end-to-end tests. Its auto-waiting capabilities eliminate flakiness, cross-browser support catches real bugs, and debugging tools make failure investigation straightforward. As you build your test suite, focus on writing tests that mirror real user behavior using semantic locators and independent test cases.

Quiz on Playwright Testing

Your Score: 0/10

Question: What browsers does Playwright support natively?

Continue Reading

Frequently Asked Questions (FAQs) / People Also Ask (PAA)

Is Playwright better than Selenium for test automation?

How does Playwright handle dynamic content and AJAX requests?

Can I run Playwright tests in parallel?

How do I debug failing Playwright tests?

Does Playwright support mobile testing?

How do I handle authentication in Playwright tests?

What's the difference between page.locator() and page.getByRole()?

Can Playwright intercept and mock network requests?