Playwright Browser Testing — End-to-End Testing Guide
Playwright is a browser automation library by Microsoft that enables reliable end-to-end testing across Chromium, Firefox, and WebKit with a single API — no browser drivers needed.
What You’ll Learn
By the end of this tutorial, you’ll be able to set up Playwright, write robust browser tests using locators and assertions, leverage test isolation for parallel execution, integrate with CI pipelines, and debug failing tests with traces and screenshots.
Why Playwright Matters
Playwright has become the go-to choice for cross-browser testing. Its auto-waiting mechanism eliminates flaky tests caused by timing issues — the most common frustration in E2E testing. At DodaTech, Playwright validates UI flows across all three browser engines for Doda Browser before every release.
Playwright Learning Path
flowchart LR
A[Testing Basics] --> B[Jest]
B --> C[Playwright]
C --> D[Cypress]
C --> E{You Are Here}
style E fill:#f90,color:#fff
Setting Up Playwright
Install Playwright with the test runner and required browsers:
npm init playwright@latestOr add it to an existing project:
npm install --save-dev @playwright/test
npx playwright installThis downloads Chromium, Firefox, and WebKit binaries. The configuration file playwright.config.ts lets you define test directories, browser projects, and global settings.
// playwright.config.js
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests',
timeout: 30000,
expect: { timeout: 5000 },
fullyParallel: true,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'webkit', use: { browserName: 'webkit' } },
],
});Locators and Assertions
Playwright’s locator API uses aria roles, text content, CSS selectors, and test IDs to find elements reliably.
// login.spec.js
const { test, expect } = require('@playwright/test');
test('user can log in', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('secret123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByTestId('user-name')).toHaveText('Welcome, User!');
});Expected output:
Running 1 test using Chromium...
✓ user can log in (2.1s)Playwright auto-waits for elements to be visible and actionable before clicking or typing — you rarely need explicit waitFor calls.
Test Isolation
Playwright creates a new browser context (isolated storage, cookies, and sessions) for every test by default.
test.describe('shopping cart', () => {
test('adds item to cart', async ({ page }) => {
await page.goto('/products');
await page.getByText('Add to Cart').first().click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
});
test('starts with empty cart in a fresh context', async ({ page }) => {
await page.goto('/cart');
await expect(page.getByTestId('cart-count')).toHaveText('0');
});
});Each test in this block starts with a clean session. No state leaks between them, which means tests can run in parallel without flakiness.
Mocking Network Requests
Playwright lets you intercept and mock API responses to test edge cases without a real backend.
// api-test.spec.js
test('displays error when API fails', async ({ page }) => {
await page.route('**/api/products', async route => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Server error' }),
});
});
await page.goto('/products');
await expect(page.getByText('Failed to load products')).toBeVisible();
});
test('shows empty state when API returns no results', async ({ page }) => {
await page.route('**/api/products', async route => {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify([]),
});
});
await page.goto('/products');
await expect(page.getByText('No products found')).toBeVisible();
});The Playwright Test Flow
flowchart LR
A[Write Test] --> B[Run npx playwright test]
B --> C{Pass?}
C -->|Yes| D[All Browsers Pass]
C -->|No| E[View Trace Viewer]
E --> F[Fix Test or Code]
F --> B
CI Integration
Playwright integrates seamlessly with CI providers. For GitHub Actions:
name: E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/This runs all tests across Chromium, Firefox, and WebKit. Failed tests produce trace files and screenshots for debugging.
Debugging with Trace Viewer
When a test fails, Playwright captures a trace — a recording of every action, network request, and console message:
npx playwright show-reportThe trace viewer shows DOM snapshots at each step, network timing, and console output so you can see exactly what happened before the failure.
Common Playwright Mistakes
1. Using Generic Locators
Selectors like page.locator('div') or page.locator('button') match too many elements and produce flaky results.
Fix: Use getByRole, getByLabel, getByTestId, or getByText for specific, resilient locators.
2. Not Using Auto-Waiting
Calling page.waitForTimeout(2000) to wait for an element introduces unnecessary delays and still doesn’t guarantee the element is ready.
Fix: Rely on Playwright’s built-in auto-waiting. Use toBeVisible, toBeEnabled, and similar assertions.
3. Skipping Cross-Browser Testing
Writing tests that only run on Chromium misses bugs specific to Firefox or WebKit.
Fix: Configure all three browser projects in playwright.config.ts and run the full matrix before merging.
4. Forgetting to Handle Asynchronous Navigation
After clicking a link that triggers navigation, trying to assert immediately can hit the old page.
Fix: Use await page.waitForURL('**/new-page') or assert on the new page’s content, which automatically waits for navigation.
5. Hardcoding Test Data
Tests that depend on specific database records fail when the data changes or is cleaned up.
Fix: Use page.route to mock API responses, or create test data programmatically via API calls in beforeEach.
6. Not Using Trace on Failure
Without traces, debugging a CI failure requires guesswork.
Fix: Set trace: 'on-first-retry' or trace: 'retain-on-failure' in the config.
7. Running Tests Against Production
Running E2E tests against production risks corrupting real data and triggering unintended actions.
Fix: Run against a dedicated staging or CI environment.
Practice Questions
1. How does Playwright’s auto-waiting differ from manual waits?
Playwright automatically waits for elements to be visible, enabled, and stable before performing actions. Manual waitForTimeout fixes are avoidable.
2. What is a browser context in Playwright?
An isolated browser session similar to an incognito window. Each test gets a fresh context with separate cookies, localStorage, and session data.
3. How do you mock a network response in Playwright?
Use page.route(pattern, handler) to intercept matching requests and call route.fulfill() with the desired status, headers, and body.
4. How do you run tests in multiple browsers?
Define projects in playwright.config.ts for each browser. Playwright runs all project configurations in parallel.
5. Challenge: Write a test for a search feature.
Navigate to a site with a search box, type a query, verify results appear, mock an empty search response, and verify the empty state message.
Mini Project: E2E Test Suite for a Todo App
// todo.spec.js
const { test, expect } = require('@playwright/test');
const TODO_ITEMS = [
'Buy groceries',
'Walk the dog',
'Write tests',
];
test.describe('Todo App', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:3000/todos');
});
test('shows empty state', async ({ page }) => {
await expect(page.getByText('No todos yet')).toBeVisible();
await expect(page.getByRole('listitem')).toHaveCount(0);
});
test('adds a new todo', async ({ page }) => {
await page.getByPlaceholder('What needs to be done?').fill(TODO_ITEMS[0]);
await page.getByRole('button', { name: 'Add' }).click();
await expect(page.getByRole('listitem')).toHaveCount(1);
await expect(page.getByText(TODO_ITEMS[0])).toBeVisible();
});
test('completes a todo', async ({ page }) => {
await page.getByPlaceholder('What needs to be done?').fill(TODO_ITEMS[0]);
await page.getByRole('button', { name: 'Add' }).click();
await page.getByRole('checkbox').check();
await expect(page.getByRole('listitem')).toHaveClass(/completed/);
});
test('persists between page reloads', async ({ page }) => {
await page.getByPlaceholder('What needs to be done?').fill(TODO_ITEMS[1]);
await page.getByRole('button', { name: 'Add' }).click();
await page.reload();
await expect(page.getByRole('listitem')).toHaveCount(1);
});
test('clears completed todos', async ({ page }) => {
await page.getByPlaceholder('What needs to be done?').fill(TODO_ITEMS[2]);
await page.getByRole('button', { name: 'Add' }).click();
await page.getByRole('checkbox').check();
await page.getByRole('button', { name: 'Clear completed' }).click();
await expect(page.getByRole('listitem')).toHaveCount(0);
});
});FAQ
Try It Yourself
- Set up a new Playwright project with
npm init playwright@latest - Write a test that navigates to a public website and asserts the page title
- Mock an API response and verify the UI handles the mocked data
- Run the test across Chromium, Firefox, and WebKit
- View the HTML report with
npx playwright show-report
What’s Next
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro