By Brian Danin |
End-to-end testing has a reputation problem. Tests are flaky, slow, and expensive to maintain. Teams start with enthusiasm, writing comprehensive test suites—only to watch them become brittle, unreliable, and eventually ignored.
Playwright changes this equation. Microsoft’s modern testing framework addresses the core pain points that plagued previous generations of browser automation tools. With its architectural improvements, developer-friendly API, and built-in best practices, Playwright makes it possible to build automated test suites that are fast, reliable, and actually maintainable.
This guide walks through everything you need to know to implement Playwright effectively—from basic setup through advanced patterns for visual regression testing, performance monitoring, and building test suites that scale.
Why Playwright Over Other Testing Tools
Before diving into implementation, it’s worth understanding what makes Playwright different from Selenium, Puppeteer, and other browser automation tools.
Modern Browser Control
Playwright uses the Chrome DevTools Protocol and similar protocols for WebKit and Firefox, enabling:
- Direct browser communication: No intermediary WebDriver servers
- Faster execution: Commands execute immediately without network overhead
- Better reliability: Built-in auto-waiting eliminates race conditions
- Network interception: Mock APIs, modify responses, test offline scenarios
Playwright Automated Browser Testing
Reliable end-to-end testing for modern web apps
No visual differences detected
Pixel match: 100%
Multi-Browser, One API
Playwright supports Chromium, Firefox, and WebKit with a single, consistent API. You don’t need different tools or syntax for different browsers—the same test runs everywhere:
// One test, all browsers
test('works everywhere', async ({ page }) => {
await page.goto('https://example.com');
await expect(page.locator('h1')).toHaveText('Welcome');
});
Run with: npx playwright test --project=chromium firefox webkit
Built-in Best Practices
Playwright enforces patterns that lead to reliable tests:
- Auto-waiting: Waits for elements to be actionable before interacting
- Web-first assertions: Retry assertions until they pass or timeout
- Isolated contexts: Each test gets a fresh browser context (cookies, storage, etc.)
- Trace viewer: Record test execution for debugging failures
Developer Experience
Playwright prioritizes developer productivity:
- Codegen: Record interactions and generate test code
- Test generator: Create tests by clicking through your app
- Inspector: Step through tests, inspect page state
- VS Code extension: Run and debug tests without leaving your editor
Getting Started with Playwright
Installation
Initialize Playwright in your project:
npm init playwright@latest
This interactive setup:
- Installs Playwright and browser binaries
- Creates example tests and configuration
- Sets up GitHub Actions workflow (optional)
For existing projects:
npm install -D @playwright/test
npx playwright install
Project Structure
Playwright generates a recommended structure:
project/
├── tests/
│ └── example.spec.ts
├── playwright.config.ts
└── package.json
The configuration file (playwright.config.ts) controls test behavior:
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',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Key Configuration Points:
fullyParallel: Run all tests in parallel for speedretries: Retry failed tests in CI to handle transient issuestrace: Record execution trace on first retry for debuggingwebServer: Automatically start your dev server before tests
Your First Test
Create tests/homepage.spec.ts:
import { test, expect } from '@playwright/test';
test('homepage loads and displays title', async ({ page }) => {
// Navigate to page
await page.goto('/');
// Verify page title
await expect(page).toHaveTitle(/Welcome/);
// Verify main heading
await expect(page.locator('h1')).toHaveText('Welcome to Our Site');
// Verify navigation is visible
await expect(page.locator('nav')).toBeVisible();
});
Run the test:
npx playwright test
Playwright will:
- Start your web server (if configured)
- Run tests in all configured browsers
- Generate an HTML report
View the report:
npx playwright show-report
Core Concepts and Patterns
Locators: Finding Elements Reliably
Playwright’s locator system is designed for reliability. Unlike traditional selectors that break when the DOM changes, Playwright locators are lazy (evaluated when needed) and strict (must match exactly one element).
Recommended Locator Strategies
1. Role-based locators (Most resilient)
// Find by ARIA role and accessible name
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('heading', { name: 'Welcome' }).isVisible();
await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com');
These selectors work even if classes, IDs, or structure changes. They also ensure your site is accessible.
2. Text content
await page.getByText('Click here to continue').click();
await page.getByLabel('Password').fill('secret');
3. Test IDs (For dynamic content)
Add data-testid attributes to elements that need stable selectors:
<button data-testid="checkout-button">Checkout</button>
await page.getByTestId('checkout-button').click();
4. CSS selectors (Last resort)
await page.locator('.submit-button').click();
Use only when other strategies don’t work. These are most fragile.
Chaining Locators
Narrow scope by chaining:
// Find button within a specific form
const form = page.locator('form#checkout');
await form.locator('button[type="submit"]').click();
// Find within a specific section
const nav = page.locator('nav[aria-label="Main"]');
await nav.getByRole('link', { name: 'About' }).click();
Auto-Waiting: The Secret to Reliable Tests
Playwright automatically waits for elements to be actionable before performing actions:
// Playwright waits for button to be:
// - Attached to DOM
// - Visible
// - Stable (not animating)
// - Enabled
// - Not covered by other elements
await page.getByRole('button', { name: 'Submit' }).click();
You rarely need manual waits like sleep() or waitForSelector(). This eliminates the most common source of flaky tests.
Assertions: Web-First and Retrying
Playwright’s expect assertions automatically retry until they pass or timeout:
// Retries until text appears (or timeout)
await expect(page.locator('.status')).toHaveText('Complete');
// Retries until element is visible
await expect(page.locator('.modal')).toBeVisible();
// Retries until count matches
await expect(page.locator('.item')).toHaveCount(5);
This handles asynchronous updates without manual waiting.
Available Assertions:
toHaveText(),toContainText()toBeVisible(),toBeHidden()toBeEnabled(),toBeDisabled()toHaveCount()toHaveURL(),toHaveTitle()toHaveAttribute()toHaveClass()
Page Object Model
For maintainable test suites, use the Page Object Model pattern to encapsulate page interactions:
// pages/LoginPage.ts
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.page.getByLabel('Email').fill(email);
await this.page.getByLabel('Password').fill(password);
await this.page.getByRole('button', { name: 'Sign In' }).click();
}
async expectErrorMessage(message: string) {
await expect(this.page.locator('.error')).toHaveText(message);
}
}
// tests/login.spec.ts
import { LoginPage } from '../pages/LoginPage';
test('shows error for invalid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('invalid@example.com', 'wrongpassword');
await loginPage.expectErrorMessage('Invalid credentials');
});
Benefits:
- Tests read like documentation
- Changes to UI require updating one place
- Reusable logic across tests
Testing Common Scenarios
Form Interactions
test('contact form submission', async ({ page }) => {
await page.goto('/contact');
// Fill form fields
await page.getByLabel('Name').fill('John Doe');
await page.getByLabel('Email').fill('john@example.com');
await page.getByLabel('Message').fill('This is a test message');
// Select from dropdown
await page.getByLabel('Subject').selectOption('Support');
// Check checkbox
await page.getByLabel('Subscribe to newsletter').check();
// Submit form
await page.getByRole('button', { name: 'Send Message' }).click();
// Verify success
await expect(page.locator('.success-message')).toBeVisible();
await expect(page).toHaveURL(/\/thank-you/);
});
Navigation and Multi-Page Flows
test('shopping cart checkout flow', async ({ page }) => {
// Add item to cart
await page.goto('/products/widget-123');
await page.getByRole('button', { name: 'Add to Cart' }).click();
// Go to cart
await page.getByRole('link', { name: 'Cart' }).click();
await expect(page).toHaveURL(/\/cart/);
// Verify item in cart
await expect(page.locator('.cart-item')).toContainText('Widget 123');
// Proceed to checkout
await page.getByRole('button', { name: 'Checkout' }).click();
// Fill shipping info
await page.getByLabel('Address').fill('123 Main St');
await page.getByLabel('City').fill('Portland');
// Complete order
await page.getByRole('button', { name: 'Place Order' }).click();
// Verify confirmation
await expect(page.locator('.order-confirmation')).toBeVisible();
});
API Mocking and Network Interception
Test frontend behavior independently of backend:
test('displays products from API', async ({ page }) => {
// Mock API response
await page.route('**/api/products', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Product A', price: 29.99 },
{ id: 2, name: 'Product B', price: 49.99 },
]),
});
});
await page.goto('/products');
// Verify products display
await expect(page.locator('.product')).toHaveCount(2);
await expect(page.locator('.product').first()).toContainText('Product A');
});
Test error handling:
test('handles API error gracefully', async ({ page }) => {
// Mock API failure
await page.route('**/api/products', route =>
route.fulfill({ status: 500 })
);
await page.goto('/products');
// Verify error message
await expect(page.locator('.error-message')).toBeVisible();
await expect(page.locator('.error-message')).toContainText('Unable to load products');
});
Authentication State
Save authentication state and reuse across tests:
// global-setup.ts
async function globalSetup() {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('http://localhost:3000/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 auth to complete
await page.waitForURL('**/dashboard');
// Save storage state
await page.context().storageState({ path: 'auth.json' });
await browser.close();
}
export default globalSetup;
// playwright.config.ts
export default defineConfig({
globalSetup: require.resolve('./global-setup'),
use: {
storageState: 'auth.json',
},
});
Now all tests run as authenticated users without repeated login.
Visual Regression Testing
Playwright includes built-in visual comparison testing:
test('homepage matches visual baseline', async ({ page }) => {
await page.goto('/');
// First run creates baseline screenshot
// Subsequent runs compare against baseline
await expect(page).toHaveScreenshot();
});
Compare specific elements:
test('product card matches design', async ({ page }) => {
await page.goto('/products');
const productCard = page.locator('.product-card').first();
await expect(productCard).toHaveScreenshot('product-card.png');
});
Configuring Visual Tests
Control screenshot behavior:
await expect(page).toHaveScreenshot({
// Clip to specific area
clip: { x: 0, y: 0, width: 800, height: 600 },
// Hide dynamic elements
mask: [page.locator('.timestamp')],
// Tolerance for small differences (0-1)
maxDiffPixels: 100,
});
Handling Dynamic Content
Mask elements that change between runs:
test('dashboard without dynamic content', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveScreenshot({
mask: [
page.locator('.current-time'),
page.locator('.live-data'),
page.locator('.user-avatar'),
],
});
});
Performance Testing
Playwright can measure web performance metrics:
test('homepage loads quickly', async ({ page }) => {
const startTime = Date.now();
await page.goto('/', { waitUntil: 'networkidle' });
const loadTime = Date.now() - startTime;
expect(loadTime).toBeLessThan(3000); // 3 seconds
});
Measuring Web Vitals
Capture Core Web Vitals:
test('measures Core Web Vitals', async ({ page }) => {
await page.goto('/');
const vitals = await page.evaluate(() => {
return new Promise((resolve) => {
let CLS = 0;
let LCP = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'largest-contentful-paint') {
LCP = entry.startTime;
}
}
}).observe({ entryTypes: ['largest-contentful-paint'] });
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
CLS += entry.value;
}
}
}).observe({ entryTypes: ['layout-shift'] });
// Wait for page to settle
setTimeout(() => resolve({ LCP, CLS }), 5000);
});
});
expect(vitals.LCP).toBeLessThan(2500); // 2.5 seconds
expect(vitals.CLS).toBeLessThan(0.1); // Cumulative Layout Shift
});
Network Performance
Track network requests:
test('limits number of requests', async ({ page }) => {
const requests: string[] = [];
page.on('request', request => {
requests.push(request.url());
});
await page.goto('/');
await page.waitForLoadState('networkidle');
// Verify reasonable number of requests
expect(requests.length).toBeLessThan(50);
// Verify no requests to blocked domains
const blockedDomain = requests.filter(url => url.includes('ads.example.com'));
expect(blockedDomain).toHaveLength(0);
});
Best Practices for Maintainable Tests
1. Test User Journeys, Not Implementation
Focus on what users do, not how the UI is structured:
Good:
test('user can purchase product', async ({ page }) => {
await page.goto('/products/widget');
await page.getByRole('button', { name: 'Add to Cart' }).click();
await page.getByRole('link', { name: 'Checkout' }).click();
// ...
});
Bad:
test('cart icon updates', async ({ page }) => {
await page.goto('/products/widget');
await page.locator('.add-cart-btn').click();
await expect(page.locator('#cart-count')).toHaveText('1');
});
2. Use Fixtures for Test Data
Create reusable test data:
// fixtures/test-users.ts
export const testUsers = {
admin: {
email: 'admin@example.com',
password: 'admin123',
},
customer: {
email: 'customer@example.com',
password: 'customer123',
},
};
import { testUsers } from '../fixtures/test-users';
test('admin can access dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.login(testUsers.admin.email, testUsers.admin.password);
// ...
});
3. Isolate Tests
Each test should be independent:
test.beforeEach(async ({ page }) => {
// Reset to known state before each test
await page.goto('/');
// Clear cookies, local storage, etc.
});
test('test A', async ({ page }) => {
// This test doesn't depend on test B
});
test('test B', async ({ page }) => {
// This test doesn't depend on test A
});
4. Use Descriptive Test Names
Test names should explain what’s being verified:
// Good
test('displays error message when email is invalid', async ({ page }) => {
// Good
test('redirects to homepage after successful login', async ({ page }) => {
// Bad
test('test login', async ({ page }) => {
// Bad
test('form validation', async ({ page }) => {
5. Don’t Over-Assert
Each test should verify one specific behavior:
Good:
test('displays validation error for empty email', async ({ page }) => {
await page.goto('/signup');
await page.getByRole('button', { name: 'Sign Up' }).click();
await expect(page.locator('.error')).toContainText('Email is required');
});
test('displays validation error for invalid email format', async ({ page }) => {
await page.goto('/signup');
await page.getByLabel('Email').fill('notanemail');
await page.getByRole('button', { name: 'Sign Up' }).click();
await expect(page.locator('.error')).toContainText('Invalid email');
});
Bad:
test('validates all form fields', async ({ page }) => {
await page.goto('/signup');
// Tests 10 different validation scenarios
// When this fails, you don't know which validation broke
});
6. Use Parallelization Wisely
Playwright runs tests in parallel by default. Ensure tests don’t interfere:
// playwright.config.ts
export default defineConfig({
fullyParallel: true,
workers: process.env.CI ? 2 : undefined,
});
For tests that must run sequentially:
test.describe.serial('checkout flow', () => {
test('add item to cart', async ({ page }) => {
// ...
});
test('proceed through checkout', async ({ page }) => {
// Depends on previous test
});
});
7. Debug with Trace Viewer
When tests fail in CI, enable trace recording:
// playwright.config.ts
export default defineConfig({
use: {
trace: 'on-first-retry',
},
});
View traces locally:
npx playwright show-trace trace.zip
The trace viewer shows:
- Screenshots at each step
- DOM snapshots
- Network activity
- Console logs
- Test source code
Integrating with CI/CD
GitHub Actions
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
GitLab CI
# .gitlab-ci.yml
stages:
- test
playwright:
stage: test
image: mcr.microsoft.com/playwright:v1.40.0-focal
script:
- npm ci
- npx playwright test
artifacts:
when: always
paths:
- playwright-report/
expire_in: 1 week
Running Tests in Docker
FROM mcr.microsoft.com/playwright:v1.40.0-focal
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npx", "playwright", "test"]
Playwright vs. Other Testing Approaches
For comprehensive testing strategies, consider how Playwright complements other testing tools. Playwright excels at end-to-end browser testing, but it’s one piece of a complete quality assurance strategy.
Testing Pyramid
A balanced testing approach typically includes:
- Unit tests (70%): Test individual functions and components in isolation
- Integration tests (20%): Test how components work together
- End-to-end tests (10%): Test complete user workflows with Playwright
Drupal Testing Strategy
Use the fastest test type that can verify your requirement
JavaScript Tests
Test with full JavaScript execution
Functional Tests
Test complete user interactions
Kernel Tests
Test with Drupal subsystems
Unit Tests
Test individual classes in isolation
Pro Tip: Start with unit tests and move up the pyramid only when necessary
See Getting Started with Automated Testing in Drupal for more details.
When to Use Playwright
Ideal for:
- Critical user journeys (signup, checkout, payment)
- Cross-browser compatibility verification
- Visual regression testing
- Performance monitoring
- Testing complex interactive features
Not ideal for:
- Testing business logic (use unit tests)
- API testing (use tools like Postman or Jest)
- Load testing (use k6 or Apache JMeter)
Complementing Framework-Specific Tests
If you’re working with a specific framework, combine Playwright with framework-level testing:
For Drupal projects, use PHPUnit for backend testing and Playwright for frontend interactions. This combination ensures both your business logic and user experience work correctly. While Drupal’s built-in testing framework handles entity operations, form validation, and business logic, Playwright tests verify the complete user experience across browsers.
For React/Vue/Angular: Use component testing libraries (React Testing Library, Vue Test Utils) alongside Playwright for full-stack coverage.
Common Challenges and Solutions
“Tests Are Flaky”
Causes:
- Not using auto-waiting properly
- Testing implementation details
- Network timing issues
- Animation interference
Solutions:
// Wait for specific state
await expect(page.locator('.data')).toBeVisible();
// Disable animations
await page.emulateMedia({ reducedMotion: 'reduce' });
// Mock network responses for consistency
await page.route('**/api/**', route => route.fulfill({...}));
“Tests Are Slow”
Solutions:
- Run tests in parallel
- Use authentication state instead of logging in each test
- Mock API responses when testing frontend
- Run only affected tests during development
// Run specific test file
npx playwright test tests/login.spec.ts
// Run tests matching pattern
npx playwright test --grep "@smoke"
“Hard to Debug Failures”
Solutions:
- Enable trace on first retry
- Use
--debugmode - Add screenshots on failure
- Use headed mode during development
# Debug mode (step through test)
npx playwright test --debug
# Headed mode (see browser)
npx playwright test --headed
# Specific browser
npx playwright test --project=chromium
“Selectors Keep Breaking”
Solutions:
- Prefer role-based selectors
- Use test IDs for dynamic content
- Avoid CSS classes tied to styling
- Use Page Object Model pattern
Getting Started: A Practical Action Plan
Week 1: Foundation
- Install Playwright and run example tests
- Write tests for 2-3 critical user journeys
- Set up continuous integration
- Configure visual regression for key pages
Week 2-3: Expansion
- Implement Page Object Model for main flows
- Add authentication state management
- Set up network mocking for API-dependent features
- Add performance monitoring tests
Week 4: Integration
- Integrate with existing testing suite
- Document testing patterns for team
- Add tests to PR workflow
- Set up automatic test runs on deployment
Conclusion: Testing as Confidence
Automated testing isn’t about achieving perfect coverage—it’s about building confidence in your application. Confidence that changes won’t break existing features. Confidence that new releases work across browsers. Confidence that performance hasn’t degraded.
Playwright makes this confidence achievable. Its modern architecture eliminates traditional pain points around flakiness and maintenance burden. The investment in automated testing pays dividends in:
- Faster release cycles: Deploy with confidence
- Better user experience: Catch issues before users do
- Lower maintenance costs: Prevent bugs rather than fix them
- Team velocity: Refactor without fear
Start with high-value tests—critical user paths like signup, checkout, or core workflows. Build the habit. Over time, you’ll create a comprehensive safety net that makes your application more reliable and your team more productive.
The future you will thank you for the tests you write today.
Need help implementing automated testing strategies for your web applications? Contact us to discuss comprehensive quality assurance approaches that combine Playwright, unit testing, and performance monitoring.