← Back to DevBytes

Testing Playwright Components: From Unit to E2E Tests

Testing Playwright Components: From Unit to E2E Tests

Playwright has rapidly become one of the most popular browser automation frameworks, but its testing capabilities extend far beyond simple end-to-end (E2E) scenarios. With the introduction of @playwright/experimental-ct, developers can now test individual components in isolation, bridging the gap between pure unit tests and full E2E tests. This tutorial walks you through the entire testing spectrum with Playwright, from isolated component unit tests to comprehensive E2E flows.

What Is Playwright Testing?

Playwright is a Node.js library for automating Chromium, Firefox, and WebKit browsers. The @playwright/test package provides a full-featured test runner with fixtures, assertions, parallel execution, and built-in reporters. On top of that, @playwright/experimental-ct adds component testing support, allowing you to mount React, Vue, Svelte, or Solid components directly in a real browser and interact with them as a user would.

This means you get three layers of testing:

Why It Matters

Each layer catches different classes of bugs at different costs. Unit tests are fast and pinpoint logic errors but miss DOM and integration issues. E2E tests catch real-world breakages but are slower and harder to debug. Component tests sit in the middle: they run in a real browser, render actual markup, and exercise user interactions, yet they remain focused on a single component. This combination gives you a fast feedback loop without sacrificing confidence.

Using Playwright for all three layers also means a single assertion library, a single fixture model, and a single reporter β€” reducing cognitive overhead and tooling sprawl across your team.

Setting Up Playwright

Start by installing the test runner and initializing the configuration. From your project root, run:

npm init playwright@latest

This scaffolds a playwright.config.ts file, a tests directory, and example specs. For component testing, install the component testing package for your framework. For React:

npm install --save-dev @playwright/experimental-ct-react

A typical playwright.config.ts for both E2E and component tests looks like this:

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

export default defineConfig({
  testDir: './tests',
  timeout: 30_000,
  expect: { timeout: 5_000 },
  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: !process.env.CI,
  },
});

Unit Testing Pure Logic

Playwright's test runner can be used for plain unit tests with no browser involved. This is ideal for utilities, reducers, and pure functions. Create a file tests/utils/formatPrice.spec.ts:

import { test, expect } from '@playwright/test';
import { formatPrice } from '../../src/utils/formatPrice';

test.describe('formatPrice', () => {
  test('formats a number as USD currency', () => {
    expect(formatPrice(1234.5)).toBe('$1,234.50');
  });

  test('handles zero', () => {
    expect(formatPrice(0)).toBe('$0.00');
  });

  test('rounds to two decimals', () => {
    expect(formatPrice(9.999)).toBe('$10.00');
  });
});

The implementation might be:

// src/utils/formatPrice.ts
export function formatPrice(value: number): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
  }).format(value);
}

These tests run in Node with no browser launch, making them extremely fast. Run them with:

npx playwright test tests/utils

Component Testing with Playwright

Component tests mount a single component in a real browser tab and let you interact with it using the same Playwright locators you use in E2E tests. This is where @playwright/experimental-ct-react shines.

Create a separate config for component tests, playwright-ct.config.ts:

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

export default defineConfig({
  testDir: './src/components',
  testMatch: /.*\.ct\.(ts|tsx)/,
  use: {
    ctPort: 3100,
    trace: 'on-first-retry',
  },
});

Now consider a Counter component:

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

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

The component test mounts this component and exercises it like a user:

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

test('increments and decrements the count', async ({ mount }) => {
  const component = await mount(<Counter initial={5} step={2} />);

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

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

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

test('starts at zero by default', async ({ mount }) => {
  const component = await mount(<Counter />);
  await expect(component.getByTestId('count')).toHaveText('0');
});

Run component tests with:

npx playwright test --config=playwright-ct.config.ts

Mocking Dependencies in Component Tests

Components often depend on external data. You can mock network requests using page.route or pass mock props. For a component that fetches data, prefer injecting a data-fetching function as a prop to keep the test deterministic:

// src/components/UserProfile.tsx
export function UserProfile({ userId, fetchUser }: {
  userId: string;
  fetchUser: (id: string) => Promise<{ name: string }>;
}) {
  const [user, setUser] = useState<{ name: string } | null>(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId, fetchUser]);

  if (!user) return <p data-testid="loading">Loading…</p>;
  return <h1 data-testid="name">{user.name}</h1>;
}
// src/components/UserProfile.ct.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { UserProfile } from './UserProfile';

test('shows the user name after loading', async ({ mount }) => {
  const fetchUser = async (id: string) => ({ name: `User ${id}` });

  const component = await mount(
    <UserProfile userId="42" fetchUser={fetchUser} />
  );

  await expect(component.getByTestId('loading')).toBeVisible();
  await expect(component.getByTestId('name')).toHaveText('User 42');
});

End-to-End Testing

E2E tests run against your full application. They validate that routing, API calls, state management, and UI all work together. Place these in the tests/e2e directory.

Imagine a simple login flow. The spec might look like this:

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

test.describe('Login flow', () => {
  test('redirects to dashboard on success', async ({ page }) => {
    await page.goto('/login');

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

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

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

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

    await expect(page.getByText('Invalid email or password')).toBeVisible();
    await expect(page).toHaveURL(/\/login$/);
  });
});

Using Page Object Models

For larger applications, the Page Object Model (POM) pattern keeps selectors and actions centralized. Create a LoginPage class:

// tests/e2e/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: 'Sign in' });
    this.errorMessage = page.getByText('Invalid email or password');
  }

  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();
  }
}

Then the spec becomes cleaner and more maintainable:

// tests/e2e/login.pom.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test('redirects to dashboard on success', async ({ page }) => {
  const login = new LoginPage(page);
  await login.goto();
  await login.login('admin@example.com', 's3cret');

  await expect(page).toHaveURL(/\/dashboard$/);
});

Authenticating Once with Storage State

Repeating login on every test slows your suite. Playwright lets you authenticate once and reuse the session via storageState:

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

setup('authenticate', async ({ page }) => {
  const login = new LoginPage(page);
  await login.goto();
  await login.login('admin@example.com', 's3cret');
  await expect(page).toHaveURL(/\/dashboard$/);

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

Reference this setup in your config as a dependency:

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

Best Practices

Running Tests in CI

Playwright ships a GitHub Action that installs browsers and caches them. A minimal workflow:

name: Playwright Tests
on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report
          retention-days: 30

Conclusion

Playwright gives you a unified toolkit to test your application at every level β€” from pure functions in Node, to isolated components in a real browser, to complete user journeys across multiple pages. By combining unit tests for speed, component tests for focused UI confidence, and E2E tests for end-to-end assurance, you build a testing pyramid that catches bugs early without slowing down your team. Start with the layer that addresses your current pain point, adopt the Page Object Model as your suite grows, and lean on traces and storage state to keep tests reliable in CI. With these patterns in place, you can ship features faster while trusting that your application behaves exactly as your users expect.

β€” Ad β€”

Google AdSense will appear here after approval

← Back to all articles