Testing & TDD

End-to-End Testing with Playwright

35 min Lesson 14 of 35

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:

  1. Create page objects for: HomePage, LoginPage, BlogPostPage, AdminPage
  2. Implement authentication fixture for admin user
  3. 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
  4. Test across Chromium, Firefox, and WebKit
  5. Test mobile viewport (hamburger menu, responsive images)
  6. Mock API responses for blog posts
  7. 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.