End-to-End Testing with Playwright
End-to-end (E2E) testing simulates real user interactions with your application, catching bugs that unit and integration tests miss. Playwright, developed by Microsoft, has become the go-to choice for modern E2E testing thanks to its speed, reliability, and cross-browser support.
In this tutorial, you’ll learn how to set up Playwright, write your first test, and build a comprehensive test suite for a real web application.
Why Choose Playwright?
Playwright stands out among E2E testing tools for several compelling reasons:
- Cross-browser support: Test on Chromium, Firefox, and WebKit with a single API
- Auto-wait: Playwright automatically waits for elements to be actionable before interacting
- Fast execution: Runs tests in parallel across multiple browser contexts
- Powerful debugging: Built-in trace viewer, VS Code extension, and browser devtools integration
- Modern features: Supports modern web features like service workers, geolocation, and permissions
Setting Up Playwright
First, create a new Node.js project and install Playwright:
mkdir playwright-demo && cd playwright-demo
npm init -y
npm install -D @playwright/test
npx playwright install --with-deps chromium
Create a playwright.config.js file to configure your test environment:
import { defineConfig } 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: { browserName: 'chromium' },
},
],
});
Writing Your First Test
Create a test file at tests/example.spec.js. We’ll test a simple todo application:
import { test, expect } from '@playwright/test';
test('can add and complete a todo', async ({ page }) => {
await page.goto('/');
// Add a new todo
await page.fill('input[placeholder="What needs to be done?"]', 'Learn Playwright');
await page.press('input[placeholder="What needs to be done?"]', 'Enter');
// Verify the todo was added
await expect(page.locator('.todo-list')).toContainText('Learn Playwright');
// Mark it as complete
await page.click('.todo-list input[type="checkbox"]');
// Verify it's marked complete
await expect(page.locator('.todo-list li')).toHaveClass(/completed/);
});
Run the tests with:
npx playwright test
Understanding Playwright’s Locators
Playwright provides multiple ways to locate elements. Choose the right locator for maximum reliability:
Text Locators
// Click a button by its text
await page.click('text=Submit');
// Find an element containing specific text
await expect(page.locator('text=Welcome')).toBeVisible();
CSS and XPath
// CSS selector
await page.click('#submit-button');
await page.click('.btn.primary');
// XPath for more complex queries
await page.click('//button[contains(@class, "submit")]');
Role-Based Locators (Recommended)
import { expect } from '@playwright/test';
// Locate by accessible name (recommended)
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com');
await page.getByRole('link', { name: 'Learn More' }).click();
// Locate by placeholder
await page.getByPlaceholder('Enter your email').fill('test@example.com');
// Locate by label
await page.getByLabel('Remember me').check();
Handling Asynchronous Operations
Modern web apps rely heavily on asynchronous behavior. Playwright’s auto-wait feature handles most cases, but you’ll occasionally need more control:
Waiting for Network Requests
// Wait for a specific API call to complete
await page.waitForResponse(response =>
response.url().includes('/api/todos') && response.status() === 200
);
// Wait for navigation after form submission
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
Waiting for Elements
// Wait for an element to appear
await expect(page.locator('.loading-spinner')).toBeHidden();
// Wait for an element to contain specific text
await expect(page.locator('.status')).toHaveText('Ready', { timeout: 10000 });
Assertions Deep Dive
Playwright’s assertion library extends Jest-like expect with powerful matchers:
import { expect } from '@playwright/test';
// Basic assertions
await expect(page.locator('h1')).toHaveText('Dashboard');
await expect(page.locator('.count')).toHaveCount(5);
// Value assertions
await expect(page.locator('#username')).toHaveValue('johndoe');
// Attribute assertions
await expect(page.locator('button')).toBeDisabled();
await expect(page.locator('input[type="checkbox"]')).toBeChecked();
// Visibility and state
await expect(page.locator('.modal')).toBeVisible();
await expect(page.locator('.hidden')).toBeHidden();
// Custom assertions with regex
await expect(page.locator('.timestamp')).toMatch(/\d{2}:\d{2}:\d{2}/);
Testing Forms
Forms are critical to test thoroughly. Here’s a comprehensive example:
test('registration form with validation', async ({ page }) => {
await page.goto('/register');
// Submit empty form
await page.click('button[type="submit"]');
// Assert validation errors appear
await expect(page.locator('#email-error')).toHaveText('Email is required');
await expect(page.locator('#password-error')).toHaveText('Password is required');
// Fill with invalid email
await page.fill('#email', 'notanemail');
await page.click('button[type="submit"]');
await expect(page.locator('#email-error')).toHaveText('Invalid email format');
// Fill with valid data
await page.fill('#email', 'test@example.com');
await page.fill('#password', 'SecureP@ss123');
await page.fill('#confirm-password', 'SecureP@ss123');
await page.click('button[type="submit"]');
// Verify successful registration
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('.welcome')).toContainText('Welcome, test@example.com');
});
Page Object Model
For larger test suites, use the Page Object Model (POM) to reduce duplication:
// pages/TodoPage.js
export class TodoPage {
constructor(page) {
this.page = page;
this.input = page.locator('input[placeholder*="todo"]');
this.todoList = page.locator('.todo-list');
}
async addTodo(text) {
await this.input.fill(text);
await this.input.press('Enter');
}
async completeTodo(text) {
const todo = this.todoList.locator('text=' + text);
await todo.locator('input[type="checkbox"]').click();
}
async isCompleted(text) {
const todo = this.todoList.locator('text=' + text);
return await todo.evaluate(el => el.classList.contains('completed'));
}
}
// tests/todo.spec.js
import { test } from '@playwright/test';
import { TodoPage } from '../pages/TodoPage';
test('todo app with POM', async ({ page }) => {
const todoPage = new TodoPage(page);
await page.goto('/');
await todoPage.addTodo('Write tests');
await todoPage.addTodo('Run tests');
await todoPage.completeTodo('Write tests');
expect(await todoPage.isCompleted('Write tests')).toBe(true);
});
Debugging Playwright Tests
When tests fail, Playwright provides powerful debugging tools:
Trace Viewer
The trace viewer captures every action, network request, and screenshot:
// In playwright.config.js
use: {
trace: 'on-first-retry', // Records trace on first retry
}
Run with trace viewer:
npx playwright show-trace test-results/trace.zip
VS Code Extension
Install the Playwright VS Code extension for:
- Run tests from inline decorators
- Pick selectors visually
- Debug with breakpoints
- Live reload tests
Browser Debugging
test('debug this test', async ({ page }) => {
await page.goto('/');
// Pause execution and open devtools
await page.pause();
// Continue from here...
});
Running Tests in CI
Configure your CI pipeline to run Playwright tests:
# .github/workflows/playwright.yml
name: Playwright Tests
on: [push, pull_request]
jobs:
test:
timeout-minutes: 60
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 Playwright tests
run: npx playwright test
- upload-artifact:
if: always()
name: playwright-report
path: playwright-report/
retention-days: 30
Best Practices
Follow these tips for maintainable, reliable test suites:
- Use role-based locators: They’re more resilient to UI changes than CSS selectors
- Keep tests independent: Each test should work in isolation
- Avoid sleeps: Use explicit waits instead
- Clean up test data: Delete or reset data after each test
- Use test fixtures: Share setup code across tests
- Run tests in parallel: Configure
fullyParallel: truefor speed - Tag tests: Use
@tagnameannotations to run specific subsets
Conclusion
Playwright provides a robust, developer-friendly framework for end-to-end testing. Its auto-wait behavior, powerful locators, and excellent debugging tools make it an excellent choice for testing modern web applications.
In this tutorial, you learned how to set up Playwright, write tests using various locator strategies, handle async operations, use assertions effectively, implement the Page Object Model, and debug test failures.
As you build out your test suite, remember that E2E tests are slow by nature—focus on testing critical user paths rather than every possible interaction. Combine E2E tests with fast unit and integration tests for comprehensive coverage.
Frequently Asked Questions
How is Playwright different from Cypress?
Playwright supports multiple browsers (Chromium, Firefox, WebKit) while Cypress only supports Chromium-based browsers. Playwright’s auto-wait feature reduces flaky tests, and it handles modern features like iframes and multiple tabs more reliably.
Can I use Playwright with other testing frameworks?
Yes! While Playwright has its own test runner, you can use Playwright with Jest, Mocha, or Vitest. However, the built-in @playwright/test runner is recommended for the best experience.
How do I test mobile websites with Playwright?
Use the deviceScaleFactor and viewport settings to simulate mobile devices:
use: {
deviceScaleFactor: 2,
isMobile: true,
hasTouch: true,
viewport: { width: 375, height: 812 },
}