← Back to DevBytes

Testing Playwright Applications: Unit Tests to Integration

Testing Playwright Applications: From Unit Tests to Integration

Playwright has rapidly become one of the most popular end-to-end testing frameworks for modern web applications. However, a robust testing strategy for Playwright-based projects goes beyond simply writing a few browser automation scripts. To build confidence in your application, you need a layered approach that spans unit tests, component tests, and full integration tests. This tutorial walks you through the complete testing pyramid for Playwright applications, with practical examples you can apply immediately.

What Is Playwright Testing?

Playwright is a Node.js library developed by Microsoft that enables reliable browser automation across Chromium, Firefox, and WebKit. While it is best known for end-to-end (E2E) testing, the Playwright ecosystem also supports unit testing, component testing, and API testing through its built-in test runner, @playwright/test. The test runner provides fixtures, assertions, parallel execution, and HTML reporting out of the box.

A complete testing strategy for a Playwright application typically includes three layers:

Why a Layered Testing Strategy Matters

Relying solely on E2E tests creates a slow, brittle test suite. E2E tests exercise the entire stack, which means they are more likely to fail due to unrelated issues like network latency or database state. By distributing tests across the pyramid, you gain faster feedback, easier debugging, and lower maintenance costs. Unit tests catch logic errors in milliseconds, component tests validate UI behavior without spinning up a full server, and integration tests confirm that everything works together from the user's perspective.

Setting Up Your Project

Start by initializing a new project and installing the Playwright test runner. The following commands assume a Node.js environment with npm.

mkdir playwright-testing-demo
cd playwright-testing-demo
npm init -y
npm install -D @playwright/test
npx playwright install

Next, create a configuration file at the root of your project. This file defines test directories, browser targets, and reporting options.

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  timeout: 30000,
  expect: { timeout: 5000 },
  fullyParallel: true,
  reporter: [['html'], ['list']],
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  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: true,
    timeout: 60000,
  },
});

The webServer property is particularly useful for integration tests because it automatically starts your application before tests run and stops it afterward.

Writing Unit Tests

Unit tests validate the smallest pieces of your application logic. The Playwright test runner can handle unit tests just as well as browser tests. The key difference is that unit tests do not use the page fixture. Instead, you import and call functions directly.

Suppose you have a utility module that calculates the total price of items in a shopping cart.

// src/utils/cart.ts
export interface CartItem {
  name: string;
  price: number;
  quantity: number;
}

export function calculateTotal(items: CartItem[]): number {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

export function applyDiscount(total: number, discountPercent: number): number {
  if (discountPercent < 0 || discountPercent > 100) {
    throw new Error('Discount must be between 0 and 100');
  }
  return Number((total * (1 - discountPercent / 100)).toFixed(2));
}

You can write unit tests for these functions in a dedicated test file.

// tests/unit/cart.spec.ts
import { test, expect } from '@playwright/test';
import { calculateTotal, applyDiscount, CartItem } from '../../src/utils/cart';

test.describe('Cart utilities', () => {
  const items: CartItem[] = [
    { name: 'Widget', price: 10.0, quantity: 2 },
    { name: 'Gadget', price: 25.5, quantity: 1 },
  ];

  test('calculateTotal returns correct sum', () => {
    expect(calculateTotal(items)).toBe(45.5);
  });

  test('calculateTotal handles empty cart', () => {
    expect(calculateTotal([])).toBe(0);
  });

  test('applyDiscount reduces total by percentage', () => {
    expect(applyDiscount(100, 20)).toBe(80);
  });

  test('applyDiscount throws on invalid percentage', () => {
    expect(() => applyDiscount(100, 150)).toThrow();
  });
});

Run only the unit tests by filtering with a directory path:

npx playwright test tests/unit

Writing Component Tests

Component testing sits between unit and E2E tests. Playwright Component Testing mounts a single component in an isolated environment and interacts with it using the same browser APIs you use in E2E tests. This is faster than full integration testing because you do not need to navigate through the entire application.

To set up component testing for a React project, install the appropriate package:

npm install -D @playwright/experimental-ct-react

Create a component test config file:

// playwright-ct.config.ts
import { defineConfig, devices } from '@playwright/experimental-ct-react';

export default defineConfig({
  testDir: './tests/component',
  use: {
    ctPort: 3100,
    trace: 'on-first-retry',
  },
});

Now consider a simple Counter component:

// src/components/Counter.tsx
import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p data-testid="count">{count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
    </div>
  );
}

Write a component test that mounts the component and verifies its behavior:

// tests/component/Counter.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Counter } from '../../src/components/Counter';

test('counter increments and decrements', async ({ mount }) => {
  const component = await mount(<Counter />);

  await expect(component.getByTestId('count')).toHaveText('0');

  await component.getByText('Increment').click();
  await component.getByText('Increment').click();
  await expect(component.getByTestId('count')).toHaveText('2');

  await component.getByText('Decrement').click();
  await expect(component.getByTestId('count')).toHaveText('1');
});

Component tests are ideal for validating interactive UI logic, form validation, and rendering edge cases without the overhead of a full application server.

Writing Integration and E2E Tests

Integration tests, often called E2E tests in the Playwright context, simulate real user journeys through your application. These tests launch a browser, navigate to pages, and interact with the DOM as a user would. They are the most powerful tests in your suite but also the slowest, so they should focus on critical user flows.

Assume your application has a login page and a dashboard. The following test verifies the full authentication flow:

// tests/integration/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Authentication flow', () => {
  test('user can log in and view dashboard', async ({ page }) => {
    await page.goto('/login');

    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('securePassword123');
    await page.getByRole('button', { name: 'Sign in' }).click();

    await expect(page).toHaveURL(/.*dashboard/);
    await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
  });

  test('invalid credentials show error message', async ({ page }) => {
    await page.goto('/login');

    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('wrongPassword');
    await page.getByRole('button', { name: 'Sign in' }).click();

    await expect(page.getByText('Invalid credentials')).toBeVisible();
    await expect(page).toHaveURL(/.*login/);
  });
});

For tests that require authenticated state across multiple pages, use Playwright's storage state feature. This lets you log in once and reuse the session across tests, dramatically reducing execution time.

// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('securePassword123');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page).toHaveURL(/.*dashboard/);

  await page.context().storageState({ path: 'tests/.auth/user.json' });
});

Update your config to run the setup as a dependency:

// playwright.config.ts (projects section)
projects: [
  {
    name: 'setup',
    testMatch: /.*\.setup\.ts/,
  },
  {
    name: 'chromium',
    dependencies: ['setup'],
    use: {
      ...devices['Desktop Chrome'],
      storageState: 'tests/.auth/user.json',
    },
  },
],

Testing APIs with Playwright

Playwright can also test backend APIs using the request fixture. This is useful for integration testing your API layer independently of the UI.

// tests/integration/api.spec.ts
import { test, expect } from '@playwright/test';

test('GET /api/products returns list', async ({ request }) => {
  const response = await request.get('/api/products');

  expect(response.ok()).toBeTruthy();
  const products = await response.json();
  expect(Array.isArray(products)).toBeTruthy();
  expect(products.length).toBeGreaterThan(0);
});

test('POST /api/products creates a product', async ({ request }) => {
  const response = await request.post('/api/products', {
    data: {
      name: 'Test Product',
      price: 19.99,
    },
  });

  expect(response.status()).toBe(201);
  const created = await response.json();
  expect(created.name).toBe('Test Product');
  expect(created.id).toBeTruthy();
});

Best Practices

Here is an example of mocking an external API response:

test('dashboard shows mocked data', async ({ page }) => {
  await page.route('**/api/external/stats', (route) => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ users: 42, revenue: 9900 }),
    });
  });

  await page.goto('/dashboard');
  await expect(page.getByText('42 users')).toBeVisible();
});

Running and Organizing Tests

Organize your test files in a way that mirrors the testing pyramid. A recommended directory structure looks like this:

tests/
  unit/
    cart.spec.ts
    validation.spec.ts
  component/
    Counter.spec.tsx
    LoginForm.spec.tsx
  integration/
    auth.spec.ts
    api.spec.ts
    checkout.spec.ts
  auth.setup.ts

Use npm scripts to run specific test layers:

{
  "scripts": {
    "test:unit": "playwright test tests/unit",
    "test:component": "playwright test --config=playwright-ct.config.ts",
    "test:e2e": "playwright test tests/integration",
    "test": "playwright test",
    "test:report": "playwright show-report"
  }
}

Conclusion

Testing Playwright applications effectively requires a deliberate, layered approach. Unit tests give you fast, targeted feedback on business logic. Component tests validate UI behavior in isolation without the cost of a full browser session. Integration and E2E tests confirm that your entire system works together from the user's perspective. By combining all three layers with Playwright's powerful test runner, fixtures, and tracing capabilities, you can build a test suite that is fast, reliable, and maintainable. Start with unit tests for your core logic, add component tests for interactive UI elements, and reserve integration tests for critical user journeys. This balanced strategy will give you the confidence to ship features quickly while keeping regressions to a minimum.

— Ad —

Google AdSense will appear here after approval

← Back to all articles