JavaScript Playwright: Browser Automation Testing
Playwright is Microsoft's answer to browser automation testing, and it's rapidly becoming the default choice for teams building modern web applications. Unlike Selenium, which feels like it was...
Key Insights
- Playwright’s auto-waiting and locator API eliminate the flaky tests that plague Selenium, while its cross-browser support surpasses Cypress’s Chromium-only limitation.
- Use accessibility-first locators (
getByRole,getByLabel) instead of CSS selectors—your tests become more resilient and you catch accessibility issues for free. - Storage state reuse cuts authentication overhead dramatically; authenticate once, save the state, and inject it into subsequent tests.
Introduction to Playwright
Playwright is Microsoft’s answer to browser automation testing, and it’s rapidly becoming the default choice for teams building modern web applications. Unlike Selenium, which feels like it was designed in a different era (because it was), Playwright embraces async JavaScript natively and handles the timing nightmares that make browser tests flaky.
The framework supports Chromium, Firefox, and WebKit out of the box. That last one matters—WebKit powers Safari, and if you’ve ever shipped a bug that only appeared on iOS, you understand why testing against it locally is valuable. Cypress, despite its popularity, still only runs on Chromium-based browsers in production environments.
Playwright’s architecture runs tests out-of-process, communicating with browsers via the DevTools Protocol. This gives you capabilities that in-process tools can’t match: multiple browser contexts, true parallelization, and the ability to intercept network requests before they hit the browser.
Setup and Configuration
Getting started takes about thirty seconds:
npm init playwright@latest
This command scaffolds everything you need: the configuration file, example tests, and optionally installs browsers. Say yes to the browsers—Playwright manages its own browser binaries, which eliminates the “works on my machine” problem.
Your project structure will look like this:
├── playwright.config.ts
├── tests/
│ └── example.spec.ts
├── tests-examples/
│ └── demo-todo-app.spec.ts
└── package.json
The configuration file is where you define how tests run. Here’s a practical setup:
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', { open: 'never' }],
['json', { outputFile: 'test-results/results.json' }]
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
The webServer option is particularly useful—Playwright will start your dev server before tests and shut it down after. No more forgetting to run your app before testing.
Writing Your First Test
Playwright tests follow a straightforward pattern: arrange, act, assert. The framework provides a test function and an expect assertion library that understands async operations.
import { test, expect } from '@playwright/test';
test('user can add item to cart', async ({ page }) => {
// Navigate to the product page
await page.goto('/products/mechanical-keyboard');
// Click the add to cart button
await page.getByRole('button', { name: 'Add to Cart' }).click();
// Verify the cart badge updates
await expect(page.getByTestId('cart-count')).toHaveText('1');
// Verify success message appears
await expect(page.getByRole('alert')).toContainText('Added to cart');
});
Notice there’s no await page.waitForSelector() or explicit timeouts. Playwright’s auto-waiting handles this automatically. When you call click(), Playwright waits for the element to be visible, enabled, and stable before clicking. When you assert with expect(), it retries until the condition passes or times out.
This auto-waiting behavior eliminates an entire category of flaky tests. The default timeout is 30 seconds for actions and 5 seconds for assertions, both configurable.
Locators and Selectors
Locators are Playwright’s abstraction for finding elements. Unlike raw selectors, locators are lazy—they don’t query the DOM until you perform an action. This means you can define locators at the top of your test and they’ll always find the current state of the element.
The framework strongly encourages accessibility-first locators:
import { test, expect } from '@playwright/test';
test('locator strategies comparison', async ({ page }) => {
await page.goto('/signup');
// Best: Role-based (accessible, resilient)
const submitButton = page.getByRole('button', { name: 'Create Account' });
// Good: Label-based (tests accessibility)
const emailInput = page.getByLabel('Email address');
// Good: Placeholder for inputs without labels
const searchInput = page.getByPlaceholder('Search products...');
// Acceptable: Test IDs (explicit contract with developers)
const promoSection = page.getByTestId('promotional-banner');
// Avoid: CSS selectors (brittle, tied to implementation)
const fragileButton = page.locator('.btn.btn-primary.submit-form');
// Avoid: XPath (unreadable, extremely brittle)
const terribleLocator = page.locator('//div[@class="form"]/button[1]');
// Fill the form using good locators
await emailInput.fill('user@example.com');
await page.getByLabel('Password').fill('securepassword123');
await submitButton.click();
});
Role-based locators query the accessibility tree, not the DOM. This means your tests verify that screen readers can navigate your application. You’re getting accessibility testing for free.
When you need to chain locators, use filter() and locator():
// Find the "Delete" button within a specific list item
const todoItem = page.getByRole('listitem').filter({ hasText: 'Buy groceries' });
await todoItem.getByRole('button', { name: 'Delete' }).click();
Handling Common Scenarios
Real applications have authentication, file handling, and API dependencies. Playwright handles all of these.
Authentication with Storage State
Logging in before every test wastes time. Instead, authenticate once and reuse the session:
// auth.setup.ts
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('test@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait for authentication to complete
await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible();
// Save signed-in state
await page.context().storageState({ path: authFile });
});
Configure this as a setup project in your config:
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
Mocking API Responses
Network interception lets you test edge cases without backend changes:
test('displays error when API fails', async ({ page }) => {
// Intercept the API call and return an error
await page.route('**/api/products', (route) => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal server error' }),
});
});
await page.goto('/products');
await expect(page.getByRole('alert')).toContainText(
'Failed to load products. Please try again.'
);
});
Debugging and Reporting
When tests fail, you need to understand why. Playwright’s debugging tools are excellent.
The Playwright Inspector lets you step through tests interactively:
npx playwright test --ui
For CI failures, traces are invaluable. They capture screenshots, DOM snapshots, network requests, and console logs at each step:
// In playwright.config.ts
use: {
trace: 'on-first-retry', // Only capture traces on retry
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
You can also capture traces programmatically for specific scenarios:
test('complex checkout flow', async ({ page, context }) => {
// Start tracing
await context.tracing.start({ screenshots: true, snapshots: true });
try {
await page.goto('/checkout');
// ... test steps
} finally {
// Save trace regardless of outcome
await context.tracing.stop({ path: 'checkout-trace.zip' });
}
});
View traces with:
npx playwright show-trace checkout-trace.zip
CI/CD Integration
Playwright runs headlessly by default in CI environments. Here’s a production-ready GitHub Actions workflow:
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
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 --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report-${{ matrix.shardIndex }}
path: playwright-report/
retention-days: 7
The sharding configuration splits your test suite across four parallel jobs. For a suite of 100 tests, each job runs approximately 25. This scales linearly—add more shards as your suite grows.
Set fail-fast: false so all shards complete even if one fails. You want the full picture of what’s broken, not just the first failure.
Playwright is the most capable browser automation tool available today. Its auto-waiting eliminates flakiness, its locator API encourages accessible applications, and its debugging tools make failure investigation straightforward. If you’re starting a new project or suffering through Selenium maintenance, make the switch.