End-to-End Testing with Playwright
Master Playwright for modern E2E testing with auto-waiting, parallel execution, and CI/CD integration for reliable browser tests
Why Playwright?
Playwright is a modern end-to-end testing framework developed by Microsoft. It supports Chromium, Firefox, and WebKit with a single API, features auto-waiting, and provides powerful tools for reliable testing. Unlike Cypress, Playwright can handle multiple tabs, browser contexts, and cross-origin scenarios natively.
🎭 Playwright Advantages:
Auto-waiting eliminates flaky tests, multi-browser support ensures cross-browser compatibility, and built-in test generation speeds up authoring.
Setting Up Playwright
# Install Playwright
npm init playwright@latest
# This creates:
# playwright.config.ts - Configuration file
# tests/ - Test directory
# tests-examples/ - Example tests
# Run tests
npx playwright test
# Run with UI mode
npx playwright test --ui
# Run specific test file
npx playwright test tests/login.spec.ts
# Show report
npx playwright show-report
// playwright.config.ts
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',
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'] } },
{ name: 'mobile', use: { ...devices['iPhone 13'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Writing Tests with Locators
Playwright uses locators that auto-wait for elements to be actionable before performing actions.
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Login Flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('should login with valid credentials', async ({ page }) => {
// Playwright auto-waits for elements
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign In' }).click();
// Assertions also auto-wait
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});
test('should show error for invalid credentials', async ({ page }) => {
await page.getByLabel('Email').fill('wrong@example.com');
await page.getByLabel('Password').fill('wrongpass');
await page.getByRole('button', { name: 'Sign In' }).click();
await expect(page.getByText('Invalid credentials')).toBeVisible();
await expect(page).toHaveURL('/login');
});
test('should validate required fields', async ({ page }) => {
await page.getByRole('button', { name: 'Sign In' }).click();
await expect(page.getByText('Email is required')).toBeVisible();
});
});
Page Object Model
Organize test logic into reusable page objects for maintainability.
// 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;
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.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 expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}
// tests/login-pom.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test('login with POM', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
Screenshots, Videos & Tracing
// Capture screenshots programmatically
test('visual check', async ({ page }) => {
await page.goto('/products');
// Full page screenshot
await page.screenshot({ path: 'screenshots/products.png', fullPage: true });
// Element screenshot
const hero = page.locator('.hero-section');
await hero.screenshot({ path: 'screenshots/hero.png' });
// Visual comparison (snapshot testing)
await expect(page).toHaveScreenshot('products-page.png', {
maxDiffPixelRatio: 0.01,
});
});
// CI/CD GitHub Actions workflow
// .github/workflows/e2e.yml
// name: E2E Tests
// on: [push, pull_request]
// jobs:
// test:
// runs-on: ubuntu-latest
// steps:
// - uses: actions/checkout@v4
// - uses: actions/setup-node@v4
// - run: npm ci
// - run: npx playwright install --with-deps
// - run: npx playwright test
// - uses: actions/upload-artifact@v4
// if: failure()
// with:
// name: playwright-report
// path: playwright-report/
🎭 Playwright vs Cypress:
Playwright Strengths:
- + Multi-browser (Chromium, Firefox, WebKit)
- + Multi-tab & multi-origin support
- + Faster parallel execution
- + Better auto-waiting
Cypress Strengths:
- + Simpler API for beginners
- + Time-travel debugging
- + Better DX for component testing
- + Larger ecosystem of plugins
Key Takeaways
- Use role-based and label-based locators for resilient selectors
- Leverage auto-waiting instead of manual waits or sleeps
- Use Page Object Model for complex test suites
- Configure screenshots, videos, and traces for debugging CI failures
- Run tests in parallel across multiple browsers for fast feedback