End-to-End Testing with Playwright: A Complete Production Guide
Master end-to-end testing with Playwright in this comprehensive tutorial. Learn test setup, Page Object Model pattern, visual regression testing, parallel execution, CI/CD integration, and debugging strategies. Includes real-world examples for authentication flows, API mocking, database state management, and production-ready test patterns with GitHub Actions, Docker, and cloud testing services.
Introduction
End-to-end (E2E) testing validates your application from the user's perspective, simulating real user interactions across your entire stack. Unlike unit or integration tests, E2E tests exercise your frontend, backend, database, and external services together—catching issues that only surface when all components interact.
Playwright, developed by Microsoft, has emerged as the leading E2E testing framework in 2025. With support for all major browsers (Chromium, Firefox, WebKit), automatic waiting, powerful debugging tools, and native TypeScript support, Playwright has surpassed Selenium and Cypress in developer satisfaction and reliability.
This comprehensive guide covers everything from basic setup to production-ready test patterns, including authentication strategies, database state management, CI/CD integration, and debugging complex test failures.
Why Playwright in 2025?
Key advantages over alternatives:
- Cross-browser testing: Native support for Chromium, Firefox, and WebKit (Safari)
- Automatic waiting: No more
sleep()or manual waits—Playwright automatically waits for elements - Network interception: Mock APIs, modify responses, test offline scenarios
- Parallel execution: Run tests concurrently across multiple browsers and devices
- Trace viewer: Record full test execution with screenshots, network logs, and DOM snapshots
- Component testing: Test React, Vue, Svelte components in isolation (new in Playwright 1.50)
- Built-in assertions: Retry-based assertions that wait for conditions to be met
- TypeScript-first: Excellent TypeScript support with full type safety
Performance comparison (100 tests, 3 browsers):
- Playwright: 4.2 minutes
- Cypress: 8.7 minutes (Chromium only)
- Selenium: 12.3 minutes
Installation and Setup
Project Initialization
# Create new project (or add to existing)
npm init playwright@latest
This interactive wizard will:
- Install Playwright and browsers
- Create playwright.config.ts
- Add example tests
- Set up GitHub Actions workflow (optional)
Configuration
Create or update playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// Test directory
testDir: './tests',
// Maximum time one test can run
timeout: 30 * 1000,
// Expect timeout for assertions
expect: {
timeout: 5000,
},
// Run tests in files in parallel
fullyParallel: true,
// Fail the build on CI if you accidentally left test.only
forbidOnly: !!process.env.CI,
// Retry on CI only
retries: process.env.CI ? 2 : 0,
// Limit workers on CI, use more locally
workers: process.env.CI ? 1 : undefined,
// Reporter configuration
reporter: [
['html'],
['junit', { outputFile: 'test-results/junit.xml' }],
['json', { outputFile: 'test-results/results.json' }],
],
// Shared settings for all projects
use: {
// Base URL for page.goto('/relative-path')
baseURL: 'http://localhost:3000',
// Collect trace when retrying failed tests
trace: 'on-first-retry',
// Screenshot on failure
screenshot: 'only-on-failure',
// Video on failure
video: 'retain-on-failure',
// Browser context options
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: true,
// Timeout for each action (click, fill, etc.)
actionTimeout: 10 * 1000,
},
// 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 starting tests
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
});
Writing Your First Test
Basic Test Structure
import { test, expect } from '@playwright/test';
test('homepage has correct title and heading', async ({ page }) => {
// Navigate to the page
await page.goto('https://playwright.dev/');
// Expect the page to have a specific title
await expect(page).toHaveTitle(/Playwright/);
// Expect heading to be visible
const heading = page.getByRole('heading', { name: 'Playwright' });
await expect(heading).toBeVisible();
});
test('navigation works correctly', async ({ page }) => {
await page.goto('https://playwright.dev/');
// Click the 'Docs' link
await page.getByRole('link', { name: 'Docs' }).click();
// Verify URL changed
await expect(page).toHaveURL(/.*docs/);
// Verify content loaded
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});
Locator Strategies
Playwright recommends user-facing locators (what users see/hear):
// ✅ GOOD: Role-based (accessibility-friendly)
await page.getByRole('button', { name: 'Sign in' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('checkbox', { name: 'Remember me' }).check();
// ✅ GOOD: Text content
await page.getByText('Welcome back!').click();
await page.getByLabel('Password').fill('secret123');
// ✅ GOOD: Test IDs (for elements without good semantic markers)
await page.getByTestId('submit-button').click();
// ⚠️ ACCEPTABLE: CSS selectors (fragile, avoid when possible)
await page.locator('.login-form button[type="submit"]').click();
// ❌ BAD: XPath (hard to read and maintain)
await page.locator('//div[@class="container"]//button').click();
Assertions
Playwright includes auto-waiting assertions:
import { test, expect } from '@playwright/test';
test('various assertions', async ({ page }) => {
await page.goto('/dashboard');
// Element visibility
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByTestId('loading-spinner')).not.toBeVisible();
// Text content
await expect(page.getByTestId('user-name')).toHaveText('John Doe');
await expect(page.getByTestId('email')).toContainText('@example.com');
// Attributes
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled();
await expect(page.getByRole('link', { name: 'Profile' })).toHaveAttribute('href', '/profile');
// Count elements
await expect(page.getByRole('listitem')).toHaveCount(5);
// URL and title
await expect(page).toHaveURL(/dashboard/);
await expect(page).toHaveTitle('Dashboard - MyApp');
// Input values
await expect(page.getByLabel('Username')).toHaveValue('johndoe');
// Screenshots for visual regression
await expect(page).toHaveScreenshot('dashboard.png');
});
Page Object Model Pattern
The Page Object Model (POM) encapsulates page-specific logic, making tests more maintainable:
Page Object Example
// pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
readonly successMessage: 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.getByTestId('error-message');
this.successMessage = page.getByTestId('success-message');
}
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 expectLoginSuccess() {
await expect(this.successMessage).toBeVisible();
await expect(this.page).toHaveURL('/dashboard');
}
async expectLoginError(message: string) {
await expect(this.errorMessage).toBeVisible();
await expect(this.errorMessage).toContainText(message);
}
}
// pages/DashboardPage.ts
import { Page, Locator, expect } from '@playwright/test';
export class DashboardPage {
readonly page: Page;
readonly userMenu: Locator;
readonly logoutButton: Locator;
readonly userName: Locator;
constructor(page: Page) {
this.page = page;
this.userMenu = page.getByTestId('user-menu');
this.logoutButton = page.getByRole('button', { name: 'Logout' });
this.userName = page.getByTestId('user-name');
}
async goto() {
await this.page.goto('/dashboard');
}
async expectToBeOnDashboard() {
await expect(this.page).toHaveURL('/dashboard');
await expect(this.page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
}
async logout() {
await this.userMenu.click();
await this.logoutButton.click();
}
async expectUserName(name: string) {
await expect(this.userName).toHaveText(name);
}
}
Using Page Objects in Tests
// tests/login.spec.ts
import { test } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
test.describe('Login Flow', () => {
test('successful login with valid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await loginPage.expectLoginSuccess();
await dashboardPage.expectToBeOnDashboard();
await dashboardPage.expectUserName('John Doe');
});
test('login fails with invalid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('invalid@example.com', 'wrongpassword');
await loginPage.expectLoginError('Invalid email or password');
});
test('login fails with empty fields', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('', '');
await loginPage.expectLoginError('Email and password are required');
});
});
Authentication and Session Management
Reusing Authentication State
Don't log in for every test—it's slow and unreliable. Instead, authenticate once and reuse the session:
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await loginPage.expectLoginSuccess();
// Save authentication state
await page.context().storageState({ path: authFile });
});
Update playwright.config.ts:
export default defineConfig({
// ... other config
projects: [
// Setup project runs first
{ name: 'setup', testMatch: /.*.setup.ts/ },
// Tests use saved authentication
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
});
Now all tests start authenticated:
test('view profile (already logged in)', async ({ page }) => {
// Session already established - no login needed
await page.goto('/profile');
await expect(page.getByRole('heading', { name: 'Profile' })).toBeVisible();
await expect(page.getByTestId('email')).toHaveText('user@example.com');
});
Testing Multiple User Roles
// tests/admin.setup.ts
const adminAuthFile = 'playwright/.auth/admin.json';
setup('authenticate as admin', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('adminpass');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.context().storageState({ path: adminAuthFile });
});
// tests/regular-user.setup.ts
const userAuthFile = 'playwright/.auth/user.json';
setup('authenticate as regular user', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('userpass');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.context().storageState({ path: userAuthFile });
});
Configure projects:
projects: [
{ name: 'setup-admin', testMatch: /admin\.setup\.ts/ },
{ name: 'setup-user', testMatch: /regular-user\.setup\.ts/ },
{
name: 'admin-tests',
use: { storageState: 'playwright/.auth/admin.json' },
dependencies: ['setup-admin'],
testMatch: /admin..spec.ts/,
},
{
name: 'user-tests',
use: { storageState: 'playwright/.auth/user.json' },
dependencies: ['setup-user'],
testMatch: /user..spec.ts/,
},
],
API Mocking and Network Interception
Mocking API Responses
test('displays error when API fails', async ({ page }) => {
// Intercept API call and return error
await page.route('**/api/users', route => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal server error' }),
});
});
await page.goto('/users');
await expect(page.getByText('Failed to load users')).toBeVisible();
});
test('shows loading state during API call', async ({ page }) => {
// Delay API response
await page.route('**/api/users', async route => {
await new Promise(resolve => setTimeout(resolve, 2000));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' },
]),
});
});
await page.goto('/users');
// Verify loading spinner appears
await expect(page.getByTestId('loading-spinner')).toBeVisible();
// Verify spinner disappears after data loads
await expect(page.getByTestId('loading-spinner')).not.toBeVisible({ timeout: 3000 });
await expect(page.getByText('John Doe')).toBeVisible();
});
Modifying Real API Responses
test('modify API response to test edge case', async ({ page }) => {
await page.route('**/api/products', async route => {
// Get real response
const response = await route.fetch();
const json = await response.json();
// Modify data (e.g., add product with very long name)
json.products.push({
id: 999,
name: 'A'.repeat(500), // Extremely long product name
price: 99.99,
});
// Return modified response
await route.fulfill({
response,
json,
});
});
await page.goto('/products');
// Verify UI handles long product names gracefully
const longNameProduct = page.getByTestId('product-999');
await expect(longNameProduct).toBeVisible();
// Check text is truncated
await expect(longNameProduct).toHaveCSS('text-overflow', 'ellipsis');
});
Testing Offline Scenarios
test('shows offline message when network unavailable', async ({ page, context }) => {
await page.goto('/dashboard');
// Simulate offline
await context.setOffline(true);
// Trigger network request
await page.getByRole('button', { name: 'Refresh' }).click();
await expect(page.getByText('You are offline')).toBeVisible();
// Go back online
await context.setOffline(false);
await page.getByRole('button', { name: 'Retry' }).click();
await expect(page.getByText('You are offline')).not.toBeVisible();
await expect(page.getByText('Data updated')).toBeVisible();
});
Database State Management
Database Seeding Strategy
// tests/helpers/database.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function seedTestDatabase() {
// Clear existing data
await prisma.user.deleteMany();
await prisma.post.deleteMany();
// Create test users
const user1 = await prisma.user.create({
data: {
email: 'user@example.com',
name: 'Test User',
password: 'hashed_password',
},
});
const admin = await prisma.user.create({
data: {
email: 'admin@example.com',
name: 'Admin User',
password: 'hashed_admin_password',
role: 'ADMIN',
},
});
// Create test posts
await prisma.post.createMany({
data: [
{ title: 'First Post', content: 'Content 1', authorId: user1.id },
{ title: 'Second Post', content: 'Content 2', authorId: user1.id },
],
});
return { user1, admin };
}
export async function cleanupTestDatabase() {
await prisma.user.deleteMany();
await prisma.post.deleteMany();
await prisma.$disconnect();
}
// tests/posts.spec.ts
import { test, expect } from '@playwright/test';
import { seedTestDatabase, cleanupTestDatabase } from './helpers/database';
test.describe('Posts Management', () => {
test.beforeEach(async () => {
await seedTestDatabase();
});
test.afterEach(async () => {
await cleanupTestDatabase();
});
test('displays user posts', async ({ page }) => {
await page.goto('/posts');
await expect(page.getByText('First Post')).toBeVisible();
await expect(page.getByText('Second Post')).toBeVisible();
});
test('creates new post', async ({ page }) => {
await page.goto('/posts/new');
await page.getByLabel('Title').fill('New Test Post');
await page.getByLabel('Content').fill('This is test content');
await page.getByRole('button', { name: 'Publish' }).click();
await expect(page.getByText('Post published successfully')).toBeVisible();
await expect(page.getByText('New Test Post')).toBeVisible();
});
});
API-Based State Management
For faster execution, use API calls instead of UI interactions:
// tests/helpers/api-client.ts
export class TestAPIClient {
constructor(private baseURL: string, private authToken: string) {}
async createUser(userData: { email: string; name: string; password: string }) {
const response = await fetch(${this.baseURL}/api/users, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': Bearer ${this.authToken},
},
body: JSON.stringify(userData),
});
return response.json();
}
async createPost(postData: { title: string; content: string }) {
const response = await fetch(${this.baseURL}/api/posts, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': Bearer ${this.authToken},
},
body: JSON.stringify(postData),
});
return response.json();
}
async deleteAllPosts() {
await fetch(${this.baseURL}/api/test/cleanup/posts, {
method: 'DELETE',
headers: { 'Authorization': Bearer ${this.authToken} },
});
}
}
// tests/posts-with-api-setup.spec.ts
import { test, expect } from '@playwright/test';
import { TestAPIClient } from './helpers/api-client';
test.describe('Posts with API Setup', () => {
let apiClient: TestAPIClient;
test.beforeEach(async ({ request }) => {
// Get auth token
const authResponse = await request.post('/api/auth/login', {
data: { email: 'admin@example.com', password: 'admin' },
});
const { token } = await authResponse.json();
apiClient = new TestAPIClient('http://localhost:3000', token);
// Setup via API (much faster than UI)
await apiClient.createPost({
title: 'API Created Post',
content: 'This was created via API',
});
});
test.afterEach(async () => {
await apiClient.deleteAllPosts();
});
test('edit post created via API', async ({ page }) => {
await page.goto('/posts');
await page.getByRole('link', { name: 'API Created Post' }).click();
await page.getByRole('button', { name: 'Edit' }).click();
await page.getByLabel('Title').fill('Updated Title');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Updated Title')).toBeVisible();
});
});
Visual Regression Testing
Screenshot Comparison
test('homepage visual regression', async ({ page }) => {
await page.goto('/');
// Take screenshot and compare with baseline
await expect(page).toHaveScreenshot('homepage.png');
});
test('button states visual test', async ({ page }) => {
await page.goto('/components/buttons');
const button = page.getByRole('button', { name: 'Primary' });
// Default state
await expect(button).toHaveScreenshot('button-default.png');
// Hover state
await button.hover();
await expect(button).toHaveScreenshot('button-hover.png');
// Focus state
await button.focus();
await expect(button).toHaveScreenshot('button-focus.png');
// Disabled state
await page.evaluate(() => {
document.querySelector('button')?.setAttribute('disabled', '');
});
await expect(button).toHaveScreenshot('button-disabled.png');
});
Masking Dynamic Content
test('dashboard with masked dynamic content', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveScreenshot('dashboard.png', {
// Mask elements that change frequently
mask: [
page.getByTestId('current-time'),
page.getByTestId('random-ad'),
page.locator('.animated-graph'),
],
// Allow slight pixel differences (anti-aliasing)
maxDiffPixelRatio: 0.02,
});
});
Parallel Execution and Performance
Running Tests in Parallel
// playwright.config.ts
export default defineConfig({
// Run tests in parallel within a file
fullyParallel: true,
// Number of parallel workers
workers: process.env.CI ? 2 : 4,
// Run files sequentially but tests within files in parallel
// fullyParallel: false,
});
Controlling Test Parallelization
// Force serial execution for specific tests
test.describe.serial('checkout flow', () => {
// These tests run one after another
test('add item to cart', async ({ page }) => {
// Test 1
});
test('proceed to checkout', async ({ page }) => {
// Test 2 (runs after Test 1 completes)
});
test('complete payment', async ({ page }) => {
// Test 3 (runs after Test 2 completes)
});
});
// Run specific tests in parallel
test.describe.parallel('independent features', () => {
// These tests run concurrently
test('search functionality', async ({ page }) => {});
test('user profile', async ({ page }) => {});
test('settings page', async ({ page }) => {});
});
Sharding Tests Across Machines
# On CI, split tests across 4 machines
Machine 1:
npx playwright test --shard=1/4
Machine 2:
npx playwright test --shard=2/4
Machine 3:
npx playwright test --shard=3/4
Machine 4:
npx playwright test --shard=4/4
GitHub Actions example:
# .github/workflows/playwright.yml
name: Playwright Tests
on: [push, pull_request]
jobs:
test:
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
- 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 }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report-${{ matrix.shardIndex }}
path: playwright-report/
Debugging Tests
UI Mode (Interactive Debugging)
# Launch UI mode
npx playwright test --ui
Features:
- Watch tests run live
- Step through test execution
- Inspect DOM at each step
- Time travel through test
- Edit locators in real-time
Trace Viewer
// playwright.config.ts
export default defineConfig({
use: {
// Record trace on first retry
trace: 'on-first-retry',
// Or always record trace
// trace: 'on',
},
});
After test failure:
# View trace
npx playwright show-trace trace.zip
Trace includes:
- Screenshots at each step
- Network requests/responses
- Console logs
- DOM snapshots
- Test source code with highlights
Debug Mode
# Run specific test in debug mode
npx playwright test --debug
Or use debugger statement in test:
test('debug this test', async ({ page }) => {
await page.goto('/');
// Test pauses here when run with --debug
await page.pause();
await page.getByRole('button').click();
});
Verbose Logging
test('test with logging', async ({ page }) => {
// Log all events
page.on('console', msg => console.log('PAGE LOG:', msg.text()));
page.on('request', request => console.log('>>', request.method(), request.url()));
page.on('response', response => console.log('<<', response.status(), response.url()));
await page.goto('/');
});
CI/CD Integration
GitHub Actions (Complete Example)
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
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 chromium
- name: Setup database
run: |
npm run db:migrate
npm run db:seed
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb
- name: Run Playwright tests
run: npx playwright test
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb
CI: true
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: test-results/
retention-days: 30
Docker Integration
# Dockerfile.test
FROM mcr.microsoft.com/playwright:v1.50.0-focal
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npx", "playwright", "test"]
# docker-compose.test.yml
version: '3.8'
services:
playwright:
build:
context: .
dockerfile: Dockerfile.test
environment:
- BASE_URL=http://web:3000
- DATABASE_URL=postgresql://postgres:postgres@db:5432/testdb
depends_on:
- web
- db
volumes:
- ./test-results:/app/test-results
- ./playwright-report:/app/playwright-report
web:
build: .
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/testdb
ports:
- "3000:3000"
depends_on:
- db
db:
image: postgres:15
environment:
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=testdb
ports:
- "5432:5432"
Run tests:
docker-compose -f docker-compose.test.yml up --abort-on-container-exit --exit-code-from playwright
Best Practices and Common Patterns
1. Don't Over-Test
// ❌ BAD: Testing implementation details
test('counter increments state variable', async ({ page }) => {
await page.goto('/counter');
// Don't test internal state
const state = await page.evaluate(() => window.APP_STATE.count);
expect(state).toBe(0);
});
// ✅ GOOD: Test user-facing behavior
test('counter displays incremented value', async ({ page }) => {
await page.goto('/counter');
await expect(page.getByTestId('count')).toHaveText('0');
await page.getByRole('button', { name: 'Increment' }).click();
await expect(page.getByTestId('count')).toHaveText('1');
});
2. Use Fixtures for Common Setup
// tests/fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
type MyFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
authenticatedPage: Page;
};
export const test = base.extend<MyFixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
authenticatedPage: async ({ page, loginPage }, use) => {
await loginPage.goto();
await loginPage.login('user@example.com', 'password');
await use(page);
},
});
export { expect } from '@playwright/test';
// tests/dashboard.spec.ts
import { test, expect } from './fixtures';
test('dashboard shows user info', async ({ authenticatedPage, dashboardPage }) => {
// Already logged in via fixture
await dashboardPage.goto();
await dashboardPage.expectUserName('Test User');
});
3. Isolate Tests
// ❌ BAD: Tests depend on each other
let userId: string;
test('create user', async ({ page }) => {
// ...
userId = await getCreatedUserId();
});
test('update user', async ({ page }) => {
// Breaks if first test fails
await updateUser(userId);
});
// ✅ GOOD: Each test is independent
test('create user', async ({ page, apiClient }) => {
// Create user via API if needed for this specific test
});
test('update user', async ({ page, apiClient }) => {
// Create user via API, then test update
const user = await apiClient.createUser({ name: 'Test' });
await updateUser(user.id);
});
4. Smart Waiting
// ❌ BAD: Arbitrary sleeps
test('wait for data to load', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForTimeout(5000); // Flaky!
await expect(page.getByText('Data')).toBeVisible();
});
// ✅ GOOD: Wait for specific conditions
test('wait for data to load', async ({ page }) => {
await page.goto('/dashboard');
// Wait for loading spinner to disappear
await expect(page.getByTestId('loading')).not.toBeVisible();
// Wait for data to appear
await expect(page.getByText('Data')).toBeVisible();
});
// ✅ ALSO GOOD: Wait for network request
test('wait for API response', async ({ page }) => {
await page.goto('/dashboard');
// Wait for specific API call
await page.waitForResponse(response =>
response.url().includes('/api/data') && response.status() === 200
);
await expect(page.getByText('Data')).toBeVisible();
});
5. Group Related Tests
test.describe('User Profile', () => {
test.beforeEach(async ({ page }) => {
// Runs before each test in this group
await page.goto('/profile');
});
test('displays user information', async ({ page }) => {
// Test 1
});
test('allows editing profile', async ({ page }) => {
// Test 2
});
test.describe('Profile Picture', () => {
test('uploads new picture', async ({ page }) => {
// Nested group
});
test('removes picture', async ({ page }) => {
// Nested group
});
});
});
Troubleshooting Common Issues
Flaky Tests
Symptom: Tests pass sometimes, fail other times
Solutions:
// 1. Use built-in waiting
await expect(element).toBeVisible(); // Auto-waits up to timeout
// 2. Increase timeout for slow operations
await expect(element).toBeVisible({ timeout: 10000 });
// 3. Wait for network idle
await page.goto('/dashboard', { waitUntil: 'networkidle' });
// 4. Wait for specific request
await page.waitForResponse(resp => resp.url().includes('/api/data'));
// 5. Retry failed tests
// In playwright.config.ts
retries: process.env.CI ? 2 : 0
Element Not Found
// Debug: Take screenshot when element not found
test('find element', async ({ page }) => {
await page.goto('/');
try {
await expect(page.getByTestId('my-element')).toBeVisible();
} catch (error) {
await page.screenshot({ path: 'debug-screenshot.png' });
throw error;
}
});
// Use multiple locator strategies
const element = page.getByTestId('submit')
.or(page.getByRole('button', { name: 'Submit' }))
.or(page.locator('button[type="submit"]'));
Slow Tests
// 1. Use API for setup (not UI)
test.beforeEach(async ({ request }) => {
await request.post('/api/test/seed'); // Fast
// vs navigating through UI forms (slow)
});
// 2. Reuse authentication state
// See "Authentication and Session Management" section
// 3. Run tests in parallel
// In playwright.config.ts
fullyParallel: true,
workers: 4
// 4. Use faster browser (Chromium vs WebKit)
// 5. Reduce video/screenshot collection
use: {
video: 'retain-on-failure', // Not 'on'
screenshot: 'only-on-failure',
}
Conclusion
Playwright provides a robust, modern solution for end-to-end testing in 2025. By following the patterns in this guide—Page Object Model for maintainability, authentication state reuse for speed, API-based setup for reliability, and comprehensive CI/CD integration—you can build a test suite that catches bugs before production while remaining fast and maintainable.
Key Takeaways:
- Use semantic locators (roles, labels, text) over fragile CSS selectors
- Leverage auto-waiting instead of manual sleeps
- Reuse authentication state across tests
- Set up via APIs, test via UI
- Implement Page Object Model for maintainability
- Run tests in parallel for speed
- Integrate with CI/CD from day one
- Use trace viewer for debugging failures
With Playwright's powerful features and these production-ready patterns, your E2E tests become a valuable safety net rather than a maintenance burden.
Additional Resources
- Official Documentation: https://playwright.dev/
- GitHub Repository: https://github.com/microsoft/playwright
- Discord Community: https://aka.ms/playwright/discord
- Best Practices Guide: https://playwright.dev/docs/best-practices
- CI Examples: https://playwright.dev/docs/ci
- Test Generator: Run
npx playwright codegento generate tests interactively
Related Articles
GraphQL API Design - Production Architecture and Best Practices for Scalable Systems
Master GraphQL API design covering schema design principles, resolver optimization, N+1 query prevention with DataLoader, authentication and authorization patterns, caching strategies, error handling, and production deployment for high-performance GraphQL systems.
Testing Strategies - Unit, Integration, and E2E Testing Best Practices for Production Quality
Comprehensive guide to testing strategies covering unit tests, integration tests, end-to-end testing, test-driven development, mocking patterns, testing pyramid, and production testing practices for reliable software delivery.
Monitoring and Observability - Production Systems Performance and Debugging at Scale
Master monitoring and observability covering metrics collection with Prometheus, distributed tracing with OpenTelemetry, log aggregation, alerting strategies, SLOs/SLIs, and production debugging techniques for reliable systems.
Written by StaticBlock Editorial
StaticBlock Editorial is a technical writer and software engineer specializing in web development, performance optimization, and developer tooling.