Ultimate Guide to Playwright for Test Automation
A comprehensive guide to Playwright — from installation and setup to writing robust UI tests, handling async operations, and integrating with CI/CD pipelines.
Playwright has rapidly become the go-to framework for modern UI test automation. Built by Microsoft and released as open source, it supports Chromium, Firefox, and WebKit — all with a single API. If you're evaluating automation tools or looking to level up your existing Playwright skills, this guide covers everything from first install to production-grade CI/CD integration.
Why Playwright?
Before diving into code, it's worth understanding what makes Playwright stand out from alternatives like Selenium and Cypress.
Auto-waiting is Playwright's biggest quality-of-life feature. Instead of manually adding waitForElement() calls or arbitrary sleep() delays, Playwright automatically waits for elements to be actionable before interacting with them. This alone eliminates an entire class of flaky tests.
True cross-browser support means you can run the same test suite against Chromium, Firefox, and WebKit (Safari's engine) without any changes. Cypress only supports Chromium-based browsers natively. Selenium supports all browsers but requires per-browser driver management.
Network interception lets you mock API responses, block third-party scripts, and simulate network conditions directly in your tests — no proxy setup required.
Parallel execution is built in. Playwright runs tests in separate browser contexts (not separate processes), making parallel execution fast and isolated.
Installation and Setup
Start with Node.js 18+ and run:
npm init playwright@latestThe interactive setup will ask you to choose TypeScript or JavaScript, where to put your tests, and whether to add a GitHub Actions workflow. Choose TypeScript — the type safety pays dividends as your suite grows.
This creates:
playwright.config.ts ← Global configuration
tests/ ← Your test files
tests-examples/ ← Sample tests to reference
Your playwright.config.ts will look like this by default:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});Writing Your First Test
Playwright uses a test and expect API that feels familiar if you've used Jest or Vitest.
import { test, expect } from '@playwright/test';
test('homepage has correct title', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/InnovateBits/);
});
test('navigation links work', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Blog' }).click();
await expect(page).toHaveURL('/blog');
});Locator Strategies
The recommended way to find elements in Playwright is through semantic locators — locators that reflect how users perceive the page rather than implementation details.
// ✅ Preferred — resilient to CSS/HTML changes
page.getByRole('button', { name: 'Submit' })
page.getByLabel('Email address')
page.getByPlaceholder('Enter your email')
page.getByText('Welcome back')
page.getByTestId('submit-btn') // data-testid attribute
// ⚠️ Acceptable — but fragile if structure changes
page.locator('.submit-button')
page.locator('#email-input')
// ❌ Avoid — XPath is brittle and hard to read
page.locator('//div[@class="container"]/button[1]')Page Object Model
For any test suite beyond a few files, the Page Object Model (POM) is essential. It encapsulates the selectors and actions for each page into a class, so when the UI changes you update one place instead of every test.
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Log in' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}Using it in a test:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test('valid login redirects to dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
test('invalid password shows error', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'wrongpassword');
await expect(loginPage.errorMessage).toBeVisible();
});Handling Async Operations
Most UI interactions are asynchronous — API calls, animations, redirects. Playwright handles the most common patterns automatically, but here are the ones to know explicitly.
Waiting for network requests
// Wait for a specific API call to complete
const responsePromise = page.waitForResponse('**/api/users');
await page.getByRole('button', { name: 'Load Users' }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);Intercepting and mocking APIs
test('shows empty state when no results', async ({ page }) => {
// Intercept the API and return mock data
await page.route('**/api/search*', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ results: [], total: 0 }),
});
});
await page.goto('/search?q=something');
await expect(page.getByText('No results found')).toBeVisible();
});File uploads and downloads
// File upload
await page.getByLabel('Upload file').setInputFiles('path/to/file.pdf');
// File download
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export CSV' }).click();
const download = await downloadPromise;
await download.saveAs('./test-results/' + download.suggestedFilename());Visual Testing
Playwright has built-in screenshot comparison. Add --update-snapshots the first time to create the baseline:
test('homepage matches snapshot', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixels: 100, // Allow minor rendering differences
});
});Running Tests
# Run all tests
npx playwright test
# Run a specific file
npx playwright test tests/login.spec.ts
# Run with UI mode (great for debugging)
npx playwright test --ui
# Run headed (see the browser)
npx playwright test --headed
# Run specific browser only
npx playwright test --project=chromium
# Debug a specific test
npx playwright test --debug tests/login.spec.tsCI/CD Integration
GitHub Actions
The npm init playwright@latest setup generates this workflow automatically. Here's a production-ready version:
name: Playwright Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
env:
BASE_URL: ${{ secrets.STAGING_URL }}
- name: Upload test report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30Jenkins
pipeline {
agent any
stages {
stage('Install') {
steps {
sh 'npm ci'
sh 'npx playwright install --with-deps'
}
}
stage('Test') {
steps {
sh 'npx playwright test --reporter=junit'
}
post {
always {
junit 'test-results/*.xml'
publishHTML([
reportDir: 'playwright-report',
reportFiles: 'index.html',
reportName: 'Playwright Report'
])
}
}
}
}
}Best Practices
Keep tests independent. Each test should set up its own state and not depend on another test having run first. Use beforeEach hooks or API calls to seed data.
Use test.describe for grouping. Organise related tests into describe blocks. This also lets you apply shared beforeEach and afterEach hooks to a group.
Avoid page.waitForTimeout(). Fixed delays make tests slow and still flaky. Use proper waitFor assertions instead:
// ❌ Fragile — fails on slow machines, wastes time on fast ones
await page.waitForTimeout(3000);
// ✅ Reliable — waits for the actual condition
await expect(page.getByText('Loading...')).toBeHidden();Store credentials in environment variables. Never hardcode passwords in tests. Use .env files locally and CI secrets in pipelines.
Set a global baseURL. Configure baseURL in playwright.config.ts and use relative URLs in page.goto(). This makes it trivial to point your suite at staging, UAT, or production.
What's Next
With Playwright running in CI and a solid Page Object Model in place, the natural next steps are:
- API testing alongside UI tests using Playwright's built-in
requestcontext - AI-assisted test generation to accelerate writing new test cases — see our guide on AI-Powered Test Generation with Playwright
- Performance testing hooks to capture metrics like LCP and FCP during test runs
Playwright's official documentation is exceptionally good — bookmark the locator API and assertions reference pages in particular.