May 01, 2024 Testing & Quality 12 min read

Testing Modern Web Applications with Playwright

Learn how to build comprehensive test suites for modern web applications using Playwright, covering end-to-end testing, component testing, and best practices.

Testing Modern Web Applications with Playwright

Testing is crucial for building reliable web applications. Playwright has emerged as a powerful tool for end-to-end testing, offering cross-browser support, reliable automation, and excellent developer experience. Whether you're building with React, Next.js, or traditional server-rendered apps, Playwright can help ensure your application works correctly.

Why Playwright?

Playwright offers several advantages over other testing tools:

  • Cross-browser testing (Chromium, Firefox, WebKit)
  • Auto-waiting for elements and network requests
  • Reliable selectors and built-in retry logic
  • Screenshot and video capture for debugging
  • Network interception for API mocking
  • Mobile device emulation

Setting Up Playwright

Install Playwright in your project:

npm init -y
npm install -D @playwright/test
npx playwright install

Create a playwright.config.ts file:

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 dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Writing Your First Test

Here's a simple test for a homepage:

import { test, expect } from '@playwright/test';

test('homepage loads correctly', async ({ page }) => {
  await page.goto('/');

  // Check page title
  await expect(page).toHaveTitle(/Matt McCarthy/);

  // Check heading
  const heading = page.getByRole('heading', { name: /Hi, I'm Matt/i });
  await expect(heading).toBeVisible();

  // Check navigation links
  const projectsLink = page.getByRole('link', { name: /projects/i });
  await expect(projectsLink).toBeVisible();
});

Testing User Interactions

Test forms, buttons, and user flows:

test('contact form submission', async ({ page }) => {
  await page.goto('/about#get-in-touch');

  // Fill out form
  await page.fill('input[name="name"]', 'John Doe');
  await page.fill('input[name="email"]', 'john@example.com');
  await page.fill('textarea[name="message"]', 'Test message');

  // Submit form
  await page.click('button[type="submit"]');

  // Verify success message
  await expect(page.getByText(/thank you/i)).toBeVisible();
});

test('navigation between pages', async ({ page }) => {
  await page.goto('/');

  // Click projects link
  await page.click('a[href="/projects"]');

  // Verify we're on projects page
  await expect(page).toHaveURL(/\/projects/);
  await expect(page.getByRole('heading', { name: /my projects/i })).toBeVisible();
});

Testing API Interactions

Mock API responses for reliable testing:

test('projects page loads projects from API', async ({ page }) => {
  // Intercept API request
  await page.route('**/api/projects', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        {
          title: 'Test Project',
          description: 'A test project',
          tags: ['React', 'TypeScript']
        }
      ])
    });
  });

  await page.goto('/projects');

  // Verify project is displayed
  await expect(page.getByText('Test Project')).toBeVisible();
});

Component Testing

For React/Next.js applications, test components in isolation:

import { test, expect } from '@playwright/experimental-ct-react';
import { ProjectCard } from './ProjectCard';

test('project card displays correctly', async ({ mount }) => {
  const component = await mount(
    <ProjectCard
      title="Test Project"
      description="Test description"
      tags={['React', 'TypeScript']}
    />
  );

  await expect(component.getByText('Test Project')).toBeVisible();
  await expect(component.getByText('Test description')).toBeVisible();
});

Best Practices

1. Use Semantic Selectors

Prefer role-based and text-based selectors:

// Good
await page.getByRole('button', { name: /submit/i }).click();
await page.getByLabel('Email').fill('test@example.com');

// Avoid
await page.click('#submit-button-123');
await page.fill('input[type="email"]', 'test@example.com');

2. Organize Tests Logically

Structure your test files by feature:

tests/
  ├── homepage.spec.ts
  ├── projects.spec.ts
  ├── blog.spec.ts
  └── contact.spec.ts

3. Use Page Object Model

Create reusable page objects:

// pages/ProjectsPage.ts
export class ProjectsPage {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto('/projects');
  }

  async getProjectCard(title: string) {
    return this.page.getByRole('article').filter({ hasText: title });
  }

  async clickProject(title: string) {
    await this.getProjectCard(title).click();
  }
}

// tests/projects.spec.ts
test('viewing project details', async ({ page }) => {
  const projectsPage = new ProjectsPage(page);
  await projectsPage.goto();
  await projectsPage.clickProject('LSTM Stock Price Prediction');
  // ...
});

4. Handle Async Operations

Playwright auto-waits, but be explicit when needed:

// Wait for network to be idle
await page.goto('/', { waitUntil: 'networkidle' });

// Wait for specific element
await page.waitForSelector('.loading-spinner', { state: 'hidden' });

// Wait for API response
await page.waitForResponse(response => 
  response.url().includes('/api/projects') && response.status() === 200
);

Visual Regression Testing

Capture and compare screenshots:

test('homepage visual regression', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('homepage.png');
});

Running Tests

Run tests in different modes:

# Run all tests
npx playwright test

# Run in UI mode (interactive)
npx playwright test --ui

# Run specific test file
npx playwright test projects.spec.ts

# Run in headed mode (see browser)
npx playwright test --headed

# Run on specific browser
npx playwright test --project=firefox

CI/CD Integration

Add Playwright to your CI pipeline:

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test
      - uses: actions/upload-artifact@v3
        if: always()
        with:
          name: playwright-report
          path: playwright-report/

Real-World Example: Testing Thrive Beyond Coaching

In my Thrive Beyond Coaching project, I use Playwright to test:

  • Page navigation across all routes
  • Contact form submission and validation
  • Responsive design on different screen sizes
  • Accessibility with automated checks
  • Performance metrics

This ensures the site works reliably for users seeking coaching services.

Conclusion

Playwright makes comprehensive testing of modern web applications straightforward. Start with critical user flows, gradually expand coverage, and use Playwright's powerful features to catch bugs before they reach production. Good testing practices lead to more reliable applications and confident deployments.

Enjoyed this post?

Check out more articles on my blog or explore my projects.