Introduction to Playwright
Playwright is a modern end-to-end testing framework developed by Microsoft that enables reliable testing across all major browser engines: Chromium, Firefox, and WebKit. It provides a unified API for browser automation with excellent developer experience and powerful features.
Why Playwright?
- Cross-browser testing: Test on Chromium, Firefox, and WebKit with a single API
- Mobile emulation: Test responsive designs with device emulation
- Auto-waiting: Intelligent waits for elements to be ready
- Parallel execution: Run tests in parallel across multiple browsers
- Network interception: Mock and modify network requests
- Tracing & debugging: Record test execution with screenshots and videos
- TypeScript support: First-class TypeScript support out of the box
Note: While Cypress focuses on developer experience, Playwright excels at cross-browser testing and advanced automation scenarios.
Installing Playwright
Install Playwright and initialize the configuration:
# Install Playwright
npm init playwright@latest
# Or install manually
npm install --save-dev @playwright/test
# Install browsers
npx playwright install
The installation creates this structure:
tests/ # Test files
playwright.config.ts # Configuration
.github/workflows/ # CI/CD configuration
Configuration
Configure Playwright in playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// Test directory
testDir: './tests',
// Run tests in parallel
fullyParallel: true,
// Fail build on CI if you accidentally left test.only
forbidOnly: !!process.env.CI,
// Retry failed tests
retries: process.env.CI ? 2 : 0,
// Number of workers (parallel execution)
workers: process.env.CI ? 1 : undefined,
// Reporter
reporter: 'html',
// Shared settings for all tests
use: {
// Base URL
baseURL: 'http://localhost:3000',
// Collect trace on first retry
trace: 'on-first-retry',
// Screenshot on failure
screenshot: 'only-on-failure',
},
// Configure projects for major browsers
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// Mobile viewports
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],
// Run local dev server before tests
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Writing Your First Test
Create a test file in the tests/ directory:
// tests/example.spec.ts
import { test, expect } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('https://playwright.dev/');
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/Playwright/);
});
test('get started link', async ({ page }) => {
await page.goto('https://playwright.dev/');
// Click the get started link.
await page.getByRole('link', { name: 'Get started' }).click();
// Expects page to have a heading with the name of Installation.
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
});
Running Tests
# Run all tests
npx playwright test
# Run tests in headed mode (see browser)
npx playwright test --headed
# Run tests in specific browser
npx playwright test --project=firefox
# Run specific test file
npx playwright test tests/example.spec.ts
# Run tests in debug mode
npx playwright test --debug
# Show test report
npx playwright show-report
Locators: Finding Elements
Playwright provides powerful locator strategies:
// By role (preferred - accessibility friendly)
await page.getByRole('button', { name: 'Submit' });
await page.getByRole('heading', { level: 1 });
await page.getByRole('textbox', { name: 'Email' });
await page.getByRole('checkbox', { name: 'I agree' });
// By label text (for form inputs)
await page.getByLabel('Email address');
// By placeholder
await page.getByPlaceholder('Enter your email');
// By text content
await page.getByText('Welcome back');
await page.getByText(/sign in/i); // Regex, case-insensitive
// By test ID
await page.getByTestId('submit-button');
// By CSS selector
await page.locator('.btn-primary');
await page.locator('#username');
// Combining locators
await page.locator('form').getByRole('button', { name: 'Submit' });
// Filtering
await page.getByRole('listitem').filter({ hasText: 'Product 1' });
// Nth element
await page.getByRole('listitem').nth(2);
Tip: Playwright locators are strict by default. If multiple elements match, the test will fail. Use first(), last(), or nth() when intentionally selecting from multiple matches.
Interactions
Perform user actions:
// Click
await page.getByRole('button', { name: 'Submit' }).click();
// Double click
await page.getByText('Edit').dblclick();
// Right click
await page.getByText('File').click({ button: 'right' });
// Type
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').type('password123'); // Types character by character
// Press keyboard keys
await page.getByLabel('Search').press('Enter');
await page.keyboard.press('Control+A');
// Select from dropdown
await page.getByLabel('Country').selectOption('United States');
await page.getByLabel('Country').selectOption({ value: 'us' });
// Check/uncheck
await page.getByLabel('I agree').check();
await page.getByLabel('Subscribe').uncheck();
// Upload file
await page.getByLabel('Upload').setInputFiles('path/to/file.pdf');
await page.getByLabel('Photos').setInputFiles(['1.jpg', '2.jpg']); // Multiple files
// Hover
await page.getByText('Tooltip').hover();
// Drag and drop
await page.getByText('Item 1').dragTo(page.getByText('Drop Zone'));
Assertions
Playwright provides auto-retrying assertions:
// Visibility
await expect(page.getByText('Welcome')).toBeVisible();
await expect(page.getByText('Loading')).toBeHidden();
// Text content
await expect(page.getByRole('heading')).toHaveText('Dashboard');
await expect(page.getByRole('alert')).toContainText('Error occurred');
// Value
await expect(page.getByLabel('Email')).toHaveValue('user@example.com');
// Attributes
await expect(page.getByRole('link')).toHaveAttribute('href', '/about');
await expect(page.getByRole('button')).toHaveClass(/btn-primary/);
// Count
await expect(page.getByRole('listitem')).toHaveCount(5);
// URL
await expect(page).toHaveURL(/.*dashboard/);
await expect(page).toHaveTitle('Dashboard');
// Enabled/Disabled
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByRole('button')).toBeDisabled();
// Checked
await expect(page.getByLabel('Terms')).toBeChecked();
await expect(page.getByLabel('Newsletter')).not.toBeChecked();
// Screenshot comparison
await expect(page).toHaveScreenshot('homepage.png');
Auto-Waiting
Playwright automatically waits for elements to be actionable:
test('auto-waiting example', async ({ page }) => {
await page.goto('/');
// Playwright waits for the button to be:
// - Attached to DOM
// - Visible
// - Stable (not animating)
// - Enabled
// - Not covered by other elements
await page.getByRole('button', { name: 'Submit' }).click();
// Wait for specific conditions
await page.getByText('Loading').waitFor({ state: 'hidden' });
await page.getByText('Success').waitFor({ state: 'visible' });
// Wait for URL change
await page.waitForURL('**/dashboard');
// Wait for network request
await page.waitForResponse(response =>
response.url().includes('/api/users') && response.status() === 200
);
// Custom timeout
await page.getByText('Slow Element').click({ timeout: 30000 });
});
Note: Playwright's auto-waiting eliminates most flaky tests. Explicit waits are rarely needed.
Network Interception
Mock and modify network requests:
test('mock API response', async ({ page }) => {
// Mock API response
await page.route('**/api/users', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' }
])
});
});
await page.goto('/users');
await expect(page.getByText('John Doe')).toBeVisible();
await expect(page.getByText('Jane Smith')).toBeVisible();
});
test('modify API response', async ({ page }) => {
await page.route('**/api/user', async route => {
// Fetch original response
const response = await route.fetch();
const json = await response.json();
// Modify response
json.name = 'Modified Name';
// Fulfill with modified response
await route.fulfill({ response, json });
});
await page.goto('/profile');
await expect(page.getByText('Modified Name')).toBeVisible();
});
test('abort requests', async ({ page }) => {
// Block analytics requests
await page.route('**/analytics/**', route => route.abort());
await page.goto('/');
});
Page Object Model
Organize tests with the Page Object pattern:
// 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: 'Login' });
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 expectErrorMessage(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}
Use the page object in tests:
// tests/login.spec.ts
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/);
});
test('failed login shows error', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('invalid@example.com', 'wrong');
await loginPage.expectErrorMessage('Invalid credentials');
});
Fixtures for Test Setup
Use fixtures to set up test prerequisites:
// fixtures/auth.ts
import { test as base } from '@playwright/test';
export const test = base.extend({
// Authenticated page fixture
authenticatedPage: async ({ page }, use) => {
// Navigate to login
await page.goto('/login');
// Perform login
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Login' }).click();
// Wait for authentication
await page.waitForURL('**/dashboard');
// Provide authenticated page to test
await use(page);
// Cleanup (logout)
await page.getByRole('button', { name: 'Logout' }).click();
}
});
export { expect } from '@playwright/test';
Use the fixture in tests:
// tests/dashboard.spec.ts
import { test, expect } from '../fixtures/auth';
test('displays user dashboard', async ({ authenticatedPage }) => {
// Already logged in via fixture
await expect(authenticatedPage.getByText('Welcome back')).toBeVisible();
});
Cross-Browser Testing
Run tests across multiple browsers:
// Run on all configured browsers
npx playwright test
// Run on specific browser
npx playwright test --project=firefox
npx playwright test --project=webkit
// Run on multiple specific browsers
npx playwright test --project=chromium --project=firefox
Mobile Emulation
Test mobile viewports and touch interactions:
test('mobile menu', async ({ page }) => {
// Emulate iPhone 12
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
// Mobile menu should be visible
await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible();
// Tap mobile menu
await page.getByRole('button', { name: 'Menu' }).tap();
// Menu items should appear
await expect(page.getByRole('navigation')).toBeVisible();
});
Debugging Tests
Playwright provides powerful debugging tools:
# Run tests in debug mode (opens Playwright Inspector)
npx playwright test --debug
# Debug specific test
npx playwright test tests/example.spec.ts --debug
# Run in headed mode to see browser
npx playwright test --headed
# Slow down execution
npx playwright test --headed --slow-mo=1000
Use debugging helpers in code:
test('debugging example', async ({ page }) => {
await page.goto('/');
// Pause execution (opens Playwright Inspector)
await page.pause();
// Take screenshot
await page.screenshot({ path: 'screenshot.png' });
// Get HTML content for debugging
const html = await page.content();
console.log(html);
// Evaluate JavaScript
const title = await page.evaluate(() => document.title);
console.log(title);
});
Trace Viewer
Record and analyze test execution:
# Record trace
npx playwright test --trace on
# View trace
npx playwright show-trace trace.zip
The trace viewer shows:
- Complete test execution timeline
- Screenshots at each step
- Network requests
- Console logs
- DOM snapshots
- Action details
Exercise:
Create a comprehensive test suite for a blog application using Playwright:
- Create page objects for: HomePage, LoginPage, BlogPostPage, AdminPage
- Implement authentication fixture for admin user
- Test user flows:
- Browse blog posts (pagination, filtering)
- Read full blog post
- Submit comment on blog post
- Admin login
- Create new blog post (with image upload)
- Edit existing blog post
- Delete blog post
- Test across Chromium, Firefox, and WebKit
- Test mobile viewport (hamburger menu, responsive images)
- Mock API responses for blog posts
- Verify screenshot for blog post cards
Best Practices
- Use role-based selectors: Prioritize
getByRole() for accessibility and stability
- Page Object Model: Encapsulate page interactions in reusable classes
- Test isolation: Each test should be independent and not rely on others
- Use fixtures: Set up common test prerequisites with fixtures
- Mock external dependencies: Stub APIs and third-party services
- Leverage auto-waiting: Trust Playwright's automatic waiting, avoid manual waits
- Test across browsers: Run tests on Chromium, Firefox, and WebKit
- Use trace viewer: Enable tracing for debugging failed tests
Common Mistakes:
- Not using locator chaining - always use the most specific locator
- Using
waitForTimeout() instead of relying on auto-waiting
- Not testing across multiple browsers
- Creating interdependent tests that fail when run in isolation
- Not using Page Object Model for complex applications
- Selecting elements by text that changes with localization
Playwright vs Cypress
Key differences to help you choose:
| Feature |
Playwright |
Cypress |
| Browser Support |
Chromium, Firefox, WebKit |
Chromium-based, Firefox |
| Mobile Testing |
Native device emulation |
Viewport emulation |
| Test Runner |
Built-in parallel execution |
Sequential (parallel in paid version) |
| API |
Promise-based (async/await) |
Command queue (chaining) |
| Developer Experience |
Excellent tooling, traces |
Excellent debugging, time travel |
In the next lesson, we'll explore API testing techniques and tools for testing backend services.