What is Playwright?
Playwright is a modern, open-source end-to-end testing framework by Microsoft that lets you write reliable, fast tests for web applications across all major browsers β Chromium, Firefox, and WebKit β using a single API.
Key Capabilities
Cross-browser
Single API works with Chromium (Chrome/Edge), Firefox, and WebKit (Safari). Write once, run everywhere.
Auto-waiting
Playwright automatically waits for elements to be actionable before performing any action. No more Thread.sleep().
API Testing Built-in
Test REST/GraphQL APIs with the same framework. Mix UI and API tests seamlessly.
Network Interception
Intercept, mock, and modify network requests in real time β great for isolating test environments.
Visual Regression
Built-in screenshot comparison for pixel-level visual testing. No extra plugins needed.
Trace Viewer
Rich debugging experience with timeline, screenshots, network, and console logs all in one view.
Playwright's Supported Languages
Playwright supports multiple programming languages. For this guide, we'll use TypeScript/JavaScript which is the primary and most feature-rich option.
Playwright vs Selenium
You've mastered Selenium β here's how Playwright compares on the things that matter most in day-to-day automation.
| Feature | Selenium | Playwright |
|---|---|---|
| Architecture | WebDriver protocol (W3C) β browser drivers required | CDP/WebSocket β direct browser communication, no separate driver |
| Setup | Install browser + matching driver (chromedriver etc.) | npx playwright install β downloads everything needed |
| Auto-waiting | Manual β explicit waits or custom helpers | Built-in β waits for actionability before every action |
| Flakiness | High β timing issues very common | Very low β auto-retry assertions, auto-wait |
| Test runner | External (JUnit, TestNG, pytest, NUnit) | Built-in (@playwright/test) β no separate runner needed |
| Parallel execution | Grid required (Selenium Grid / Selenoid) | Native β workers per file or per test, no infrastructure needed |
| API Testing | Not built-in β need RestAssured / Axios separately | Built-in request context β same framework |
| Network mocking | Limited (BrowserMob proxy etc.) | Native route() API β no external tools |
| Iframes | switchTo().frame() β easy to lose context | frameLocator() β contextual, no switching needed |
| Shadow DOM | Needs JavaScript execution | Transparent β works through shadow roots automatically |
| Visual testing | External tools needed | Built-in toMatchSnapshot() |
| Debugging | Standard browser DevTools | Trace Viewer, Inspector, step-through debugger |
| Screenshots/Video | Screenshots only (manually) | Screenshots + Video + Traces (configurable) |
| Multiple tabs/contexts | Complex β manual window handles | First-class BrowserContext β isolated, easy to manage |
| Mobile emulation | Basic viewport only | Full device emulation (geolocation, touch, viewport) |
| Language support | JS, Python, Java, C#, Ruby | JS/TS, Python, Java, C# (TypeScript best supported) |
In Playwright you think: "Describe what you want to do β Playwright handles the rest."
Playwright's auto-wait eliminates most of the timing logic you wrote in Selenium. Trust it.
Architecture Overview
Understanding Playwright's three-level hierarchy is essential. Everything in Playwright flows through: Browser β BrowserContext β Page.
WebDriver = one browser session with global cookies.In Playwright:
Browser is the process, BrowserContext is the session (like an incognito profile), Page is a tab. This lets you run multiple isolated sessions in the same browser process β great for testing multi-user flows.
How Playwright Communicates with Browsers
Installation & Setup
Prerequisites
node --version. That's it β no browser drivers, no Selenium Grid, no separate test runner to install.
Quick Start (Recommended)
# Create a new Playwright project (interactive setup wizard) npm init playwright@latest # Or add to an existing project npm install --save-dev @playwright/test # Install browsers (Chromium, Firefox, WebKit) npx playwright install # Install only specific browsers npx playwright install chromium npx playwright install chromium firefox
What the Setup Wizard Creates
my-project/ βββ tests/ # Your test files go here β βββ example.spec.ts βββ tests-examples/ # Example tests from Playwright β βββ demo-todo-app.spec.ts βββ playwright.config.ts # Main configuration file βββ package.json βββ .github/ βββ workflows/ βββ playwright.yml # CI/CD workflow (GitHub Actions)
Running Tests
# Run all tests (headless by default) npx playwright test # Run with visible browser (headed mode) npx playwright test --headed # Run specific file npx playwright test tests/login.spec.ts # Run tests matching a title npx playwright test -g "should login successfully" # Run on specific browser npx playwright test --project=chromium npx playwright test --project=firefox # Debug mode (opens Playwright Inspector) npx playwright test --debug # Open HTML test report npx playwright show-report
VS Code Extension (Highly Recommended)
Install the Playwright Test for VS Code extension by Microsoft. It gives you:
- Run/debug individual tests from the editor
- Test results inline with pass/fail indicators
- Record tests using Codegen
- Pick locators interactively
code --install-extension ms-playwright.playwright
Your First Test
Let's write a complete test from scratch and understand every line.
// 1. Import the test runner and assertion library import { test, expect } from '@playwright/test'; // 2. A test file can have multiple test() blocks // test() takes: (name, async function with { page }) test('user can log in successfully', async ({ page }) => { // 3. Navigate to a URL await page.goto('https://example.com/login'); // 4. Locate elements (Playwright's recommended approach) await page.getByLabel('Email').fill('user@example.com'); await page.getByLabel('Password').fill('secret123'); // 5. Click a button by its accessible role + name await page.getByRole('button', { name: 'Sign in' }).click(); // 6. Assert the result await expect(page).toHaveURL('/dashboard'); await expect(page.getByText('Welcome back')).toBeVisible(); }); test('shows error with invalid credentials', async ({ page }) => { await page.goto('https://example.com/login'); await page.getByLabel('Email').fill('bad@user.com'); await page.getByLabel('Password').fill('wrong'); await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page.getByRole('alert')).toContainText('Invalid credentials'); });
Test Anatomy
Generating Tests with Codegen (Great Starting Point!)
# Record your interactions and generate test code automatically npx playwright codegen https://example.com # Record and save to file npx playwright codegen --target typescript -o tests/recorded.spec.ts https://example.com # Record for a specific viewport (mobile) npx playwright codegen --viewport-size="375,667" https://example.com
Locators & Selectors
Locators are how Playwright finds elements on the page. Playwright strongly recommends using semantic, user-visible locators over CSS/XPath β they're more resilient and closer to how users interact with your app.
driver.findElement(By.xpath("//button[@type='submit']"))In Playwright:
page.getByRole('button', { name: 'Submit' })The Playwright version is shorter, more readable, and won't break if the DOM structure changes β it finds the button the same way a screen reader would.
The Locator Priority Order (Best to Worst)
getByRole() β Most Recommended
Finds elements by ARIA role and accessible name. Mirrors how screen readers and users perceive the page. Most resilient to HTML changes.
getByLabel() β For Form Inputs
Finds form fields by their associated <label> text. Works with aria-label and aria-labelledby too.
getByPlaceholder() β For Input Fields
Finds inputs by their placeholder attribute text.
getByText() β Visible Text Content
Finds elements by their visible text content. Great for buttons, headings, paragraphs.
getByAltText() / getByTitle() β Media & Tooltip Elements
For images (alt text) and elements with title attributes.
getByTestId() β Test-Specific Attributes
Uses data-testid (or custom attribute). Requires adding attributes to your app code, but is very stable.
locator('css') / locator('xpath') β Escape Hatch
When none of the above work. CSS is preferred over XPath. Keep these specific and as short as possible.
Locator Examples
// ββ getByRole ββββββββββββββββββββββββββββββββββββββββββββββββββ page.getByRole('button'); // any button page.getByRole('button', { name: 'Submit' }); // button with text "Submit" page.getByRole('link', { name: 'Home' }); // <a> with text "Home" page.getByRole('checkbox'); // checkbox input page.getByRole('heading', { name: 'Products' });// h1-h6 with text page.getByRole('textbox', { name: 'Search' }); // input type=text page.getByRole('listitem'); // <li> elements page.getByRole('row'); // table rows // ββ getByLabel βββββββββββββββββββββββββββββββββββββββββββββββββ page.getByLabel('Email address'); // <input> with <label>Email address</label> page.getByLabel('Subscribe to newsletter'); // checkbox with label // ββ getByPlaceholder βββββββββββββββββββββββββββββββββββββββββββ // Use when there is no label β finds by placeholder attribute page.getByPlaceholder('Search products...'); page.getByPlaceholder('Enter your email'); // β οΈ Prefer getByLabel when a label exists β placeholder is a fallback // ββ getByAltText / getByTitle ββββββββββββββββββββββββββββββββββ page.getByAltText('Company logo'); // <img alt="Company logo"> page.getByTitle('Close dialog'); // element with title="Close dialog" // ββ getByText ββββββββββββββββββββββββββββββββββββββββββββββββββ // β οΈ Default is SUBSTRING match (case-sensitive), NOT exact! page.getByText('Welcome back, John'); // substring match (default) page.getByText('Welcome back, John', { exact: true }); // exact full-string match page.getByText('Welcome'); // matches any element containing 'Welcome' page.getByText(/welcome/i); // regex (case insensitive) // ββ getByTestId βββββββββββββββββββββββββββββββββββββββββββββββββ page.getByTestId('submit-button'); // data-testid="submit-button" // ββ locator (CSS / XPath fallback) βββββββββββββββββββββββββββββ page.locator('.btn-primary'); // CSS class page.locator('#login-form'); // CSS ID page.locator('input[type="email"]'); // CSS attribute page.locator('//button[@type="submit"]'); // XPath
Chaining & Filtering Locators
// ββ Chaining: narrow scope to a parent element βββββββββββββββββ // Find the Submit button INSIDE a specific form page.locator('#checkout-form').getByRole('button', { name: 'Submit' }); // ββ filter(): narrow a locator result by text or inner locator β // Find all list items, but only the ones containing "urgent" page.getByRole('listitem').filter({ hasText: 'urgent' }); // Find table rows that contain a specific cell value page.getByRole('row').filter({ hasText: 'John Doe' }); // ββ nth(): picking from a list βββββββββββββββββββββββββββββββββ page.getByRole('listitem').first(); // first item page.getByRole('listitem').last(); // last item page.getByRole('listitem').nth(2); // 0-indexed, so this is 3rd // ββ and(): combine locators (must match BOTH) ββββββββββββββββββ page.getByRole('button').and(page.locator('.primary')); // ββ or(): match either locator βββββββββββββββββββββββββββββββββ page.getByRole('button', { name: 'OK' }).or(page.getByRole('button', { name: 'Accept' })); // ββ frameLocator(): interact with iframes βββββββββββββββββββββ const frame = page.frameLocator('iframe[title="Payment form"]'); await frame.getByLabel('Card number').fill('4111111111111111');
Actions (Interactions)
Actions are how you interact with the page β clicking, typing, selecting, uploading files, and more. Every action auto-waits for the element to be ready before executing.
Mouse & Keyboard Actions
// ββ Click variants βββββββββββββββββββββββββββββββββββββββββββββ await locator.click(); // regular click await locator.click({ button: 'right' }); // right-click await locator.dblclick(); // double-click await locator.click({ modifiers: ['Shift'] }); // Shift+Click await locator.click({ position: { x: 10, y: 20 } }); // click at offset // ββ Text input βββββββββββββββββββββββββββββββββββββββββββββββββ await locator.fill('Hello World'); // clears and types (recommended) await locator.clear(); // clear the field await locator.pressSequentially('Hello'); // types char by char (for fields with JS events) // ββ Keyboard βββββββββββββββββββββββββββββββββββββββββββββββββββ await locator.press('Enter'); // press a key await locator.press('Control+A'); // key combo await locator.press('ArrowDown'); // arrow keys await locator.press('Tab'); // tab navigation await page.keyboard.type('Hello World'); // global keyboard (use when no focus) // ββ Select dropdowns ββββββββββββββββββββββββββββββββββββββββββ await locator.selectOption('option-value'); // by value attribute await locator.selectOption({ label: 'United States' }); // by visible text await locator.selectOption(['val1', 'val2']); // multi-select // ββ Checkboxes & Radio buttons ββββββββββββββββββββββββββββββββ await locator.check(); // check a checkbox await locator.uncheck(); // uncheck await locator.setChecked(true); // set to specific state // ββ Mouse operations ββββββββββββββββββββββββββββββββββββββββββ await locator.hover(); // mouse over await locator.focus(); // focus the element await locator.blur(); // remove focus // ββ Drag and drop ββββββββββββββββββββββββββββββββββββββββββββ await page.dragAndDrop('#source', '#target'); await locator.dragTo(page.locator('#target')); // ββ File upload ββββββββββββββββββββββββββββββββββββββββββββββββ await page.getByLabel('Upload file').setInputFiles('path/to/file.pdf'); await page.getByLabel('Upload').setInputFiles(['file1.jpg', 'file2.jpg']); // ββ Screenshots βββββββββββββββββββββββββββββββββββββββββββββββ await page.screenshot({ path: 'screenshot.png' }); await page.screenshot({ path: 'full.png', fullPage: true }); await locator.screenshot(); // screenshot of just the element // ββ Scrolling βββββββββββββββββββββββββββββββββββββββββββββββββ await locator.scrollIntoViewIfNeeded(); // scroll element into view await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); // ββ Mobile tap (for touch events) βββββββββββββββββββββββββββββ await locator.tap(); // touch tap (use with mobile device emulation)
Handling Dialogs (alert / confirm / prompt)
// ββ Register handler BEFORE the action that triggers the dialog β // β οΈ If you don't handle a dialog, Playwright auto-dismisses it // Accept all dialogs (alert / confirm / beforeunload) page.on('dialog', dialog => dialog.accept()); // Inspect and selectively handle page.on('dialog', async dialog => { console.log(dialog.type()); // 'alert' | 'confirm' | 'prompt' | 'beforeunload' console.log(dialog.message()); // the dialog text if (dialog.type() === 'confirm') { await dialog.accept(); // click OK } else { await dialog.dismiss(); // click Cancel } }); // Handle a prompt() and enter a value page.on('dialog', dialog => dialog.accept('My typed value'));
Handling New Tabs & Popups
// ββ Wait for a new page to open (link with target="_blank") ββββ // IMPORTANT: Set up the waitForEvent BEFORE clicking the link const pagePromise = page.waitForEvent('popup'); await page.getByRole('link', { name: 'Open in new tab' }).click(); const newPage = await pagePromise; // the new tab's Page object await newPage.waitForLoadState(); await expect(newPage).toHaveTitle(/Expected Title/); // ββ Handle window.open() popups ββββββββββββββββββββββββββββββββ const [popup] = await Promise.all([ context.waitForEvent('page'), // listen on context level page.getByRole('button', { name: 'Share' }).click(), ]); await popup.waitForLoadState(); // Now interact with popup like any page
Auto-waiting: What Actually Happens
Assertions & Expectations
Playwright uses the expect() API for assertions. The key feature: assertions automatically retry until the condition is met or the timeout is reached β no more flaky tests from race conditions.
Assert.assertEquals("Welcome", driver.findElement(...).getText()) β instant, fails immediately if condition isn't met.In Playwright:
await expect(locator).toHaveText('Welcome') β retries for up to 5 seconds before failing. This eliminates the need for explicit waits before assertions.
Page Assertions
// URL assertions await expect(page).toHaveURL('https://example.com/dashboard'); await expect(page).toHaveURL(/dashboard/); // regex // Page title await expect(page).toHaveTitle('My App - Dashboard'); await expect(page).toHaveTitle(/Dashboard/); // Visual regression (screenshots) await expect(page).toMatchSnapshot(); await expect(page).toMatchSnapshot({ maxDiffPixelRatio: 0.01 });
Element Assertions
const btn = page.getByRole('button', { name: 'Submit' }); // Visibility await expect(btn).toBeVisible(); // element is in DOM and visible await expect(btn).toBeHidden(); // element is hidden or not in DOM await expect(btn).toBeAttached(); // element is in the DOM // Enabled/disabled state await expect(btn).toBeEnabled(); // not disabled await expect(btn).toBeDisabled(); // is disabled // Text content await expect(btn).toHaveText('Submit Order'); // exact text match await expect(btn).toContainText('Submit'); // partial text match await expect(page.locator('ul')).toHaveText(['Item 1', 'Item 2']); // array of texts // Form values await expect(page.getByLabel('Email')).toHaveValue('user@example.com'); await expect(page.getByLabel('Accept')).toBeChecked(); // Attributes & CSS await expect(btn).toHaveAttribute('type', 'submit'); await expect(btn).toHaveClass(/btn-primary/); await expect(btn).toHaveCSS('color', 'rgb(255, 0, 0)'); // Count await expect(page.getByRole('listitem')).toHaveCount(5); // Negate with .not await expect(btn).not.toBeVisible(); await expect(page).not.toHaveURL('/error');
Advanced: expect.poll() and toPass()
// ββ expect.poll() β retry a non-Playwright value ββββββββββββββ // Perfect when checking a DB value, API response, or custom condition await expect.poll(async () => { const response = await page.request.get('/api/job-status'); return (await response.json()).status; }, { timeout: 30000, // poll for up to 30s intervals: [1000, 2000, 5000], // backoff intervals }).toBe('completed'); // ββ expect(fn).toPass() β retry a block of assertions βββββββββ // Retries the entire block until all assertions inside pass await expect(async () => { const response = await page.request.get('/api/report'); expect(response.status()).toBe(200); const data = await response.json(); expect(data.rows.length).toBeGreaterThan(0); }).toPass({ timeout: 30000 });
Soft Assertions (Don't Stop on Failure)
// Soft assertions: test continues even if assertion fails // All failures are reported at the end of the test test('validate product page', async ({ page }) => { await page.goto('/product/123'); // These won't stop the test if they fail await expect.soft(page.getByRole('heading')).toHaveText('Product Name'); await expect.soft(page.getByText('$99.99')).toBeVisible(); await expect.soft(page.getByRole('button', { name: 'Add to Cart' })).toBeEnabled(); // This is a hard assertion β still stops if it fails await expect(page).toHaveURL(/product/); });
Waiting Strategies
Playwright's auto-wait handles 90% of cases. Here's when and how to handle the other 10%.
Playwright: Everything is implicitly waited. You only add waits for complex async scenarios.
// ββ Auto-wait (built-in, no code needed) ββββββββββββββββββββββ // These all auto-wait for the element to be actionable: await locator.click(); // waits: visible, stable, enabled, in viewport await locator.fill('text'); // waits: visible, stable, enabled, editable await expect(locator).toBeVisible(); // retries assertion for 5s // ββ Wait for navigation ββββββββββββββββββββββββββββββββββββββββ await page.waitForURL('**/dashboard'); // wait for URL to match await page.waitForURL(/dashboard/, { timeout: 10000 }); // ββ Wait for element state βββββββββββββββββββββββββββββββββββββ await locator.waitFor({ state: 'visible' }); // visible, hidden, attached, detached await locator.waitFor({ state: 'hidden' }); // ββ Wait for network βββββββββββββββββββββββββββββββββββββββββββ // Wait for a specific API call to complete (great for async forms) const responsePromise = page.waitForResponse('**/api/users'); await page.getByRole('button', { name: 'Save' }).click(); const response = await responsePromise; expect(response.status()).toBe(200); // Wait for a specific outgoing request to be sent const requestPromise = page.waitForRequest('**/api/analytics'); await page.getByRole('button', { name: 'Track' }).click(); const req = await requestPromise; expect(req.method()).toBe('POST'); // Wait for page to have no pending network requests await page.waitForLoadState('networkidle'); // β οΈ Use sparingly β can be slow await page.waitForLoadState('domcontentloaded'); // ββ Wait for a function / condition βββββββββββββββββββββββββββ await page.waitForFunction(() => window.myAppReady === true); // ββ AVOID: Hard sleeps (like Selenium's Thread.sleep) βββββββββ // β await page.waitForTimeout(3000); // Only for debugging!
Configuration (playwright.config.ts)
The config file is the control center of your test suite β define browsers, timeouts, retries, base URL, reporters, and more.
import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ // Where to look for test files testDir: './tests', // Run all tests in parallel by default fullyParallel: true, // CI: don't run test.only β fail build if committed accidentally forbidOnly: !!process.env.CI, // Retry failed tests β 0 locally, 2 in CI retries: process.env.CI ? 2 : 0, // β οΈ undefined = HALF the logical CPU count (not full count) workers: process.env.CI ? 4 : undefined, // Reporters: list = terminal, html = HTML report file reporter: [ ['list'], // terminal output ['html', { open: 'never' }], // HTML report (don't auto-open) ['junit', { outputFile: 'results.xml' }], // for CI ], // Run a global setup script ONCE before the entire test suite globalSetup: './global-setup.ts', // Run a global teardown script ONCE after all tests finish globalTeardown: './global-teardown.ts', // Configure assertion retry timeout (default: 5000ms) expect: { timeout: 5000, }, // Global settings applied to all tests use: { // Base URL β use relative URLs in tests: page.goto('/login') baseURL: process.env.BASE_URL || 'http://localhost:3000', // Collect traces on first retry (for debugging CI failures) trace: 'on-first-retry', // Screenshot on failure screenshot: 'only-on-failure', // Video recording video: 'on-first-retry', // Default timeout for actions (30s) actionTimeout: 30000, // Default timeout for navigation navigationTimeout: 30000, }, // Test projects = matrix of browser + config combinations projects: [ // Run setup project first (e.g., global login) { name: 'setup', testMatch: '**/*.setup.ts', }, // Desktop browsers { name: 'chromium', use: { ...devices['Desktop Chrome'] }, dependencies: ['setup'], }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, }, { name: 'webkit', use: { ...devices['Desktop Safari'] }, }, // Mobile emulation { name: 'mobile-chrome', use: { ...devices['Pixel 5'] }, }, { name: 'mobile-safari', use: { ...devices['iPhone 13'] }, }, ], // Start dev server before running tests (optional) webServer: { command: 'npm run start', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, }, });
Timeout Hierarchy
Hooks & Test Organization
import { test, expect } from '@playwright/test'; // Group related tests test.describe('Shopping Cart', () => { // Runs ONCE before all tests in this describe block test.beforeAll(async ({ browser }) => { // Create shared resources (e.g., database setup) }); // Runs before EACH test β most common hook test.beforeEach(async ({ page }) => { await page.goto('/cart'); // Set up a known state before each test }); // Runs after EACH test test.afterEach(async ({ page }, testInfo) => { if (testInfo.status !== 'passed') { await page.screenshot({ path: `failure-${testInfo.title}.png` }); } }); // Runs ONCE after all tests test.afterAll(async () => { // Cleanup shared resources (e.g., delete test data) }); test('can add item to cart', async ({ page }) => { /* ... */ }); test('can remove item from cart', async ({ page }) => { /* ... */ }); }); // ββ Test control βββββββββββββββββββββββββββββββββββββββββββββββ test.only('run only this test', async ({ page }) => { /* ... */ }); test.skip('skip this test', async ({ page }) => { /* ... */ }); test.fixme('known broken test', async ({ page }) => { /* ... */ }); test.fail('expected to fail', async ({ page }) => { /* ... */ }); // Conditional skip test.skip(process.platform === 'win32', 'Not supported on Windows'); // ββ Test tags (for filtering) βββββββββββββββββββββββββββββββββ // Old approach: embed @tag in test name test('checkout flow @smoke @critical', async ({ page }) => { /* ... */ }); // New approach (v1.42+): use the tag option β preferred test('checkout flow', { tag: ['@smoke', '@critical'] }, async ({ page }) => { /* ... */ }); // Run: npx playwright test --grep @smoke // Skip: npx playwright test --grep-invert @slow // ββ test.step() β group actions into labeled steps βββββββββββββ // Steps appear as collapsible sections in the Trace Viewer test('complete checkout', async ({ page }) => { await test.step('Add product to cart', async () => { await page.goto('/products'); await page.getByRole('button', { name: 'Add to cart' }).click(); }); await test.step('Proceed to checkout', async () => { await page.getByRole('link', { name: 'Checkout' }).click(); await expect(page).toHaveURL(/checkout/); }); await test.step('Fill shipping details', async () => { await page.getByLabel('Address').fill('123 Main St'); await page.getByRole('button', { name: 'Place Order' }).click(); }); await expect(page.getByText('Order confirmed')).toBeVisible(); });
Page Object Model
POM separates page interaction logic from test logic. If the UI changes, you update one class β not every test. Your Selenium POM knowledge applies directly here.
import { Page, Locator } from '@playwright/test'; export class LoginPage { readonly page: Page; // Define locators as class properties 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: 'Sign 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(); } async getErrorText(): Promise<string> { return await this.errorMessage.innerText(); } }
import { test, expect } from '@playwright/test'; import { LoginPage } from '../pages/LoginPage'; test('successful login', async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.login('user@example.com', 'password123'); await expect(page).toHaveURL('/dashboard'); });
Fixtures
Fixtures are Playwright's powerful dependency injection system. Instead of creating page objects in every test, fixtures provide them automatically β with proper setup and teardown.
page, browser, context are all built-in fixtures.
import { test as base } from '@playwright/test'; import { LoginPage } from './pages/LoginPage'; import { DashboardPage } from './pages/DashboardPage'; // Define the type for our custom fixtures type MyFixtures = { loginPage: LoginPage; dashboardPage: DashboardPage; loggedInPage: DashboardPage; // already logged-in state }; export const test = base.extend<MyFixtures>({ // Auto-create LoginPage for every test that requests it loginPage: async ({ page }, use) => { await use(new LoginPage(page)); }, dashboardPage: async ({ page }, use) => { await use(new DashboardPage(page)); }, // A fixture that logs in before the test, tears down after loggedInPage: async ({ page }, use) => { const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.login('user@test.com', 'password'); // β Setup β the 'use' call is where the test body runs β Teardown β await use(new DashboardPage(page)); // Cleanup happens here after test completes await page.evaluate(() => localStorage.clear()); }, }); // Re-export expect so tests only import from this file export { expect } from '@playwright/test';
import { test, expect } from '../fixtures'; // your custom test object // No login code needed β fixture handles it! test('dashboard shows user name', async ({ loggedInPage }) => { await expect(loggedInPage.userGreeting).toBeVisible(); }); test('login form validates email', async ({ loginPage }) => { await loginPage.goto(); await loginPage.login('notanemail', 'pass'); // assertions... });
API Testing
Playwright has a built-in API testing context β no need for a separate tool like RestAssured or Axios in your test suite. This lets you mix UI and API tests seamlessly.
import { test, expect, request } from '@playwright/test'; // ββ Standalone API test (no browser) ββββββββββββββββββββββββββ test('GET /api/users returns list', async ({ request }) => { const response = await request.get('https://api.example.com/users'); expect(response.status()).toBe(200); expect(response.headers()['content-type']).toContain('application/json'); const body = await response.json(); expect(body.users).toHaveLength(10); expect(body.users[0]).toMatchObject({ id: expect.any(Number), email: expect.stringContaining('@'), }); }); test('POST /api/users creates a user', async ({ request }) => { const response = await request.post('/api/users', { data: { name: 'Jane Doe', email: 'jane@example.com' }, headers: { 'Authorization': 'Bearer token123' }, }); expect(response.status()).toBe(201); const user = await response.json(); expect(user.id).toBeDefined(); }); // ββ Hybrid: API setup + UI verification βββββββββββββββββββββββ test('created product appears in product list', async ({ request, page }) => { // 1. Create data via API (fast, no UI needed) const apiResponse = await request.post('/api/products', { data: { name: 'Test Widget', price: 9.99 } }); const { id } = await apiResponse.json(); // 2. Verify it shows in the UI await page.goto('/products'); await expect(page.getByText('Test Widget')).toBeVisible(); // 3. Cleanup via API await request.delete(`/api/products/${id}`); });
API Context with Base URL & Auth
import { request } from '@playwright/test'; // Create a reusable authenticated API context const apiContext = await request.newContext({ baseURL: 'https://api.example.com', extraHTTPHeaders: { 'Accept': 'application/json', 'Authorization': `Bearer ${process.env.API_TOKEN}`, }, }); // Now use relative URLs await apiContext.get('/users'); // β https://api.example.com/users await apiContext.post('/orders', { data: { /* ... */ } }); // Always dispose when done await apiContext.dispose();
Network Interception
Playwright's page.route() lets you intercept any network request, modify it, mock the response, or abort it β perfect for testing error states, offline scenarios, and slow APIs.
// ββ Mock an API response ββββββββββββββββββββββββββββββββββββββββ await page.route('**/api/products', async route => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([ { id: 1, name: 'Mocked Product', price: 19.99 } ]), }); }); // ββ Test error states (500, 404, network failure) ββββββββββββββ await page.route('**/api/orders', async route => { await route.fulfill({ status: 500, body: 'Internal Server Error' }); }); // ββ Abort a request (simulate network offline) βββββββββββββββββ await page.route('**/*.png', route => route.abort()); // ββ Modify a real request (add headers, change body) ββββββββββ await page.route('**/api/**', async route => { const headers = { ...route.request().headers(), 'x-test-header': 'true' }; await route.continue({ headers }); // continue with modified request }); // ββ Load response from file (fixtures) ββββββββββββββββββββββββ await page.route('**/api/products', route => route.fulfill({ path: 'tests/fixtures/products.json' // serve from local file })); // ββ Remove route when done βββββββββββββββββββββββββββββββββββββ await page.unroute('**/api/products'); // ββ Intercept and inspect a request βββββββββββββββββββββββββββ page.on('request', req => { if (req.url().includes('/api/')) { console.log(`β ${req.method()} ${req.url()}`); } });
route.fetch() β Modify Real Responses
The most powerful pattern: make the real network request, then modify the response before Playwright hands it back to the browser. Perfect for changing one field in a large API response without mocking the whole thing.
// ββ Fetch the real response, then modify it βββββββββββββββββββ await page.route('**/api/products', async route => { // 1. Make the REAL request const response = await route.fetch(); const json = await response.json(); // 2. Modify a single field (e.g. inject an out-of-stock item) json[0].stock = 0; // 3. Return the modified response to the browser await route.fulfill({ response, json }); }); // ββ Add latency to test loading/skeleton states ββββββββββββββββ await page.route('**/api/heavy-report', async route => { await page.waitForTimeout(2000); // simulate 2s delay await route.continue(); }); // ββ Remove a field to test missing-data UI states βββββββββββββ await page.route('**/api/user', async route => { const response = await route.fetch(); const json = await response.json(); delete json.avatar; // test "no profile photo" UI state await route.fulfill({ response, json }); });
Authentication & Storage State
Instead of logging in through the UI before every test (slow!), Playwright can save and reuse authenticated browser state. One login β entire test suite reuses the session.
import { test as setup, expect } from '@playwright/test'; const authFile = 'playwright/.auth/user.json'; setup('authenticate', async ({ page }) => { await page.goto('/login'); 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'); // Save cookies + localStorage to a file await page.context().storageState({ path: authFile }); });
projects: [ { name: 'setup', testMatch: '**/*.setup.ts', }, { name: 'chromium', use: { ...devices['Desktop Chrome'], // Load saved auth state β every test starts already logged in! storageState: 'playwright/.auth/user.json', }, dependencies: ['setup'], // run setup project first }, ],
admin.setup.ts, user.setup.ts, guest.setup.ts. Use different storageState files per project. Each test project can simulate a different user role β no UI login in tests at all.
Visual Regression Testing
Playwright's built-in snapshot testing compares screenshots pixel-by-pixel to catch unintended UI changes.
toMatchSnapshot() β for arbitrary data snapshots (JSON, text, binary). Use this for non-screenshot comparisons, not for UI testing.
// ββ toHaveScreenshot() β recommended for UI visual testing ββββ test('homepage visual snapshot', async ({ page }) => { await page.goto('/'); await expect(page).toHaveScreenshot('homepage.png'); // full page }); // Compare a specific element (component-level visual test) test('product card visual', async ({ page }) => { await page.goto('/products/1'); await expect(page.getByTestId('product-card')).toHaveScreenshot('product-card.png'); }); // With tolerance for minor differences await expect(page).toHaveScreenshot({ maxDiffPixelRatio: 0.01, // allow 1% pixel difference threshold: 0.2, // per-pixel color diff tolerance (0β1) fullPage: true, // capture full scrollable page }); // Mask dynamic content (dates, avatars, ads) to avoid false failures await expect(page).toHaveScreenshot({ mask: [page.getByTestId('dynamic-date'), page.getByTestId('user-avatar')], }); // First run CREATES the baseline snapshots (.png files in __screenshots__ folder) // Subsequent runs COMPARE against the baseline // To update baselines: npx playwright test --update-snapshots
--update-snapshots intentionally, never automatically in CI. Consider masking dynamic content (dates, ads, avatars) with mask option.
Parallel Execution
Playwright runs tests in parallel by default β no Selenium Grid required. Understanding how this works helps you write safe parallel tests.
// ββ playwright.config.ts βββββββββββββββββββββββββββββββββββββββ // Run test files in parallel (default) fullyParallel: false, // files in parallel, tests within file = serial fullyParallel: true, // EVERYTHING in parallel (tests within files too) workers: 4, // fixed number of workers workers: '50%', // 50% of CPU count workers: undefined, // auto (defaults to CPU count) // ββ In test files βββββββββββββββββββββββββββββββββββββββββββββ // Force a describe block to run serially (e.g., dependent tests) test.describe.serial('user checkout flow', () => { test('add to cart', async ({ page }) => { /* ... */ }); test('checkout', async ({ page }) => { /* ... */ }); // runs after 'add to cart' }); // ββ Test sharding for large suites ββββββββββββββββββββββββββββ # CI: split across 3 machines # Machine 1: npx playwright test --shard=1/3 # Machine 2: npx playwright test --shard=2/3 # Machine 3: npx playwright test --shard=3/3
Debugging Tools
Playwright has the best debugging experience of any test framework. Multiple powerful tools at your disposal.
Playwright Inspector
# Open Playwright Inspector β step through tests npx playwright test --debug # Debug a specific test npx playwright test tests/login.spec.ts --debug # Set PWDEBUG=1 to open inspector for all runs PWDEBUG=1 npx playwright test # Pause in middle of test (add to code) # await page.pause(); # View trace files after a run npx playwright show-trace trace.zip
π Playwright UI Mode β Interactive Test Runner
--ui) is a full GUI for running and debugging tests interactively. It combines the test runner, trace viewer, time-travel debugging, and live reload all in one window. For day-to-day development, it's faster than CLI + Inspector separately.
# Launch interactive UI Mode npx playwright test --ui # What you get in UI Mode: # β File tree on the left β click any test to run it # β Watch mode β tests re-run on file save # β Time-travel trace viewer built in β click any action to replay # β Filter by tag, status, or keyword # β Pick locator tool directly in the browser window # β Run failed tests only with one click
Trace Viewer
// playwright.config.ts β configure when to collect traces use: { trace: 'off', // never (production) trace: 'on', // always trace: 'on-first-retry', // β recommended for CI trace: 'retain-on-failure',// save trace only when test fails }, // Manually start/stop tracing await context.tracing.start({ screenshots: true, snapshots: true }); // ... test actions ... await context.tracing.stop({ path: 'trace.zip' }); // View it: npx playwright show-trace trace.zip
Useful Debugging Snippets
// Pause execution β opens Inspector, lets you explore the page await page.pause(); // Check what a locator matches const count = await page.getByRole('button').count(); console.log(`Found ${count} buttons`); // Get all texts from a list const texts = await page.getByRole('listitem').allTextContents(); // Print the HTML of an element for debugging const html = await page.locator('#my-component').innerHTML(); // Highlight element (inject red border for 2s) await page.locator('#element').evaluate(el => el.style.outline = '3px solid red' ); // Screenshot at a specific point for debugging await page.screenshot({ path: 'debug-state.png' }); // testInfo in hooks β access test details + attach custom files to report test('example', async ({ page }, testInfo) => { console.log(testInfo.title); // test name console.log(testInfo.status); // passed/failed/skipped console.log(testInfo.retry); // which retry attempt (0 = first run) console.log(testInfo.outputDir);// where test output files are saved }); // ββ testInfo.attach() β embed custom files in the HTML report ββ test.afterEach(async ({ page }, testInfo) => { if (testInfo.status !== 'passed') { // Attach a screenshot β visible in HTML report await testInfo.attach('on-failure screenshot', { body: await page.screenshot(), contentType: 'image/png', }); // Attach a log file or JSON data await testInfo.attach('api-response', { body: JSON.stringify(someApiData, null, 2), contentType: 'application/json', }); } });
Reporters & CI/CD
Built-in Reporters
| Reporter | Use case | Config |
|---|---|---|
list | Default terminal output β shows each test as it runs | ['list'] |
dot | Minimal output β a dot per test, good for CI | ['dot'] |
html | Rich HTML report with screenshots, traces, history | ['html', { open: 'never' }] |
json | Machine-readable output for custom processing | ['json', { outputFile: 'results.json' }] |
junit | JUnit XML β for Jenkins, Azure DevOps, etc. | ['junit', { outputFile: 'results.xml' }] |
github | Annotates GitHub PRs with test failures | ['github'] |
blob | For merging sharded test results | ['blob'] |
GitHub Actions CI Setup
name: Playwright Tests on: push: branches: [main, develop] pull_request: branches: [main] jobs: test: timeout-minutes: 60 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 tests run: npx playwright test env: BASE_URL: https://staging.example.com API_TOKEN: ${{ secrets.API_TOKEN }} - name: Upload test report uses: actions/upload-artifact@v4 if: always() # upload even when tests fail! with: name: playwright-report path: playwright-report/ retention-days: 14
Mobile, Media & Multi-browser Testing
import { test, devices } from '@playwright/test'; // Test on a specific device emulation test.use({ ...devices['iPhone 14 Pro'] }); test('mobile checkout', async ({ page }) => { await page.goto('/checkout'); // Test on a 390x844 viewport with touch events }); // Geolocation testing test('shows local stores', async ({ browser }) => { const context = await browser.newContext({ geolocation: { latitude: 28.6139, longitude: 77.2090 }, // New Delhi permissions: ['geolocation'], }); const page = await context.newPage(); // ... test geolocation-based features }); // Run with a specific locale/timezone test.use({ locale: 'en-IN', timezoneId: 'Asia/Kolkata', });
page.emulateMedia() β Dark Mode, Print, Forced Colours
// ββ Dark mode visual testing βββββββββββββββββββββββββββββββββββ test('dark mode renders correctly', async ({ page }) => { await page.emulateMedia({ colorScheme: 'dark' }); await page.goto('/'); await expect(page).toHaveScreenshot('homepage-dark.png'); }); // ββ Or configure it in test.use() for a whole describe block βββ test.use({ colorScheme: 'dark' }); // 'light' | 'dark' | 'no-preference' // ββ Test print layout ββββββββββββββββββββββββββββββββββββββββββ await page.emulateMedia({ media: 'print' }); await expect(page).toHaveScreenshot('invoice-print.png'); // ββ Forced colors (accessibility) βββββββββββββββββββββββββββββ await page.emulateMedia({ forcedColors: 'active' }); // ββ Reduce motion (accessibility) β also useful in CI ββββββββββ await page.emulateMedia({ reducedMotion: 'reduce' });
Parameterized & Data-Driven Tests
Run the same test logic across multiple data sets without code duplication. Three clean patterns β pick the one that fits your scenario.
// ββ simplest approach: loop over an array of data ββββββββββββββ const browsers = [ { user: 'admin@test.com', role: 'admin', canDelete: true }, { user: 'editor@test.com', role: 'editor', canDelete: false }, { user: 'viewer@test.com', role: 'viewer', canDelete: false }, ]; browsers.forEach(({ user, role, canDelete }) => { test(`${role} can${canDelete ? '' : 'not'} delete records`, async ({ page }) => { await page.goto('/login'); await page.getByLabel('Email').fill(user); // ... const deleteBtn = page.getByRole('button', { name: 'Delete' }); if (canDelete) { await expect(deleteBtn).toBeVisible(); } else { await expect(deleteBtn).toBeHidden(); } }); });
// ββ Load data from a JSON file and inject via fixture βββββββββ // tests/data/login-scenarios.json // [ // { "email": "valid@user.com", "password": "correct", "expectURL": "/dashboard" }, // { "email": "invalid@user.com", "password": "wrong", "expectError": "Invalid credentials" } // ] import scenarios from './data/login-scenarios.json'; for (const scenario of scenarios) { test(`login: ${scenario.email}`, async ({ page }) => { await page.goto('/login'); await page.getByLabel('Email').fill(scenario.email); await page.getByLabel('Password').fill(scenario.password); await page.getByRole('button', { name: 'Sign in' }).click(); if (scenario.expectURL) { await expect(page).toHaveURL(scenario.expectURL); } else { await expect(page.getByRole('alert')).toContainText(scenario.expectError); } }); }
// playwright.config.ts β run same tests with different env data projects: [ { name: 'staging-chrome', use: { ...devices['Desktop Chrome'], baseURL: 'https://staging.example.com', }, }, { name: 'production-chrome', use: { ...devices['Desktop Chrome'], baseURL: 'https://www.example.com', }, }, ], // Same tests run against both environments β one project per env // Run only staging: npx playwright test --project=staging-chrome
Best Practices
Test Design
β One test, one behaviour
Each test should verify one user scenario. Keep tests short and focused β easier to debug when they fail.
β Independent tests
Never rely on test execution order. Each test should set up and clean up its own state. Use beforeEach / API calls to prepare data.
β Use getByRole first
Semantic locators are resilient. If devs rename a CSS class, your test still passes. If they rename an ARIA label, it was an accessibility regression anyway.
β Never use hardcoded waits
Replace waitForTimeout(3000) with a meaningful assertion or waitForResponse(). Hard sleeps are flaky and slow.
β Use environment variables
Never hardcode URLs, credentials, or tokens. Use .env files and process.env. Use .env.example to document required variables.
β API for test setup/teardown
Create and delete test data via APIs, not UI interactions. UI setup is slow β API setup keeps tests fast and isolated.
Anti-patterns to Avoid
// β Fragile XPath β breaks when HTML structure changes page.locator('//div[@class="container"]/div[2]/button[1]'); // β Role-based β resilient page.getByRole('button', { name: 'Submit Order' }); // β Hard sleep β flaky and slow await page.waitForTimeout(3000); // β Wait for something meaningful await expect(page.getByText('Order confirmed')).toBeVisible(); // β Testing implementation details await expect(page.locator('.btn-success')).toHaveClass('btn-success'); // β Test the user-visible outcome await expect(page.getByRole('status')).toHaveText('Order placed successfully'); // β Chained awaits that could fail silently const el = await page.locator('.item').elementHandle(); // avoid ElementHandles // β Use locators directly await page.locator('.item').click(); // β Logging in via UI before every test test.beforeEach(async ({ page }) => { await page.goto('/login'); await page.fill('#email', 'user@test.com'); // SLOW }); // β Use storageState to reuse auth (see Auth section) storageState: 'playwright/.auth/user.json',
Naming Conventions
tests/ βββ auth/ β βββ login.spec.ts β feature.spec.ts β βββ registration.spec.ts βββ checkout/ β βββ cart.spec.ts β βββ payment.spec.ts βββ *.setup.ts β global setup files Test names: "feature β scenario β expected outcome" β "login β valid credentials β redirects to dashboard" β "checkout β empty cart β shows empty state message" β "test1" β "loginTest"
π Quick Reference Card
Locator Priority (best β last)
getByRole()getByLabel()getByPlaceholder()getByText()getByTestId()locator('css')
Most Used Assertions
toBeVisible()toHaveText()toHaveURL()toBeEnabled()toHaveScreenshot()toHaveCount()
Key CLI Commands
playwright test--ui(UI mode)--debug--headedshow-reportcodegen <url>
Key Patterns
storageState(auth)route.fetch()test.step()expect.poll()page.on('dialog')waitForEvent('popup')