Playwright Component Testing lets you test React components in isolation, but it’s not about running them in a browser.

Here’s a React component we’ll use:

// src/components/Counter.jsx
import React, { useState } from 'react';

function Counter({ initialCount = 0 }) {
  const [count, setCount] = useState(initialCount);

  return (
    <div>
      <p>Current count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
    </div>
  );
}

export default Counter;

And here’s how you’d mount and test it using Playwright’s component testing utilities:

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

test('should render with initial count', async ({ mount }) => {
  await mount(<Counter initialCount={5} />);
  await expect(page.locator('p')).toHaveText('Current count: 5');
});

test('should increment count', async ({ mount }) => {
  await mount(<Counter />);
  await page.getByRole('button', { name: 'Increment' }).click();
  await expect(page.locator('p')).toHaveText('Current count: 1');
});

test('should decrement count', async ({ mount }) => {
  await mount(<Counter />);
  await page.getByRole('button', { name: 'Decrement' }).click();
  await expect(page.locator('p')).toHaveText('Current count: -1');
});

This setup allows you to treat your components like miniature applications. You mount the component in a test environment, and then use Playwright’s familiar page object to interact with it and make assertions. The key difference from end-to-end tests is that you’re not navigating to a URL; you’re directly injecting your component into a controlled testing DOM.

The core problem Playwright Component Testing solves is the difficulty of testing isolated UI components effectively. Traditional unit tests often struggle to accurately simulate the browser environment and complex interactions that components rely on. Mocking all browser APIs and events can become incredibly brittle and time-consuming. Playwright bridges this gap by providing a real browser environment for your components, but without the overhead of a full application lifecycle.

Internally, Playwright Component Testing uses a development server that serves your components. When you run mount(<MyComponent />), Playwright injects this component into the DOM managed by the test runner. This server is optimized for rapid feedback, allowing for quick compilation and hot module replacement. The mount function is a wrapper around a specific framework adapter (like @playwright/experimental-ct-react for React). This adapter handles the intricacies of rendering your component within the testing DOM, including setting up the necessary React context or providers if you were to pass them to mount.

The page object you use in component tests is not the same page object as in end-to-end tests. While it shares the same API for interacting with the DOM, it’s scoped specifically to the component you’ve mounted. This means selectors like page.locator('p') will only target elements within your mounted component, preventing interference from other parts of a larger application or test setup. This isolation is crucial for reliable component-level testing.

You can pass props and children directly to your component within the mount call, just as you would in a regular React application. For example, to test a Card component that accepts children:

// src/components/Card.spec.js
import { test, expect } from '@playwright/experimental-ct-react';
import Card from './Card';

test('should render children', async ({ mount }) => {
  await mount(
    <Card>
      <p>This is inside the card.</p>
    </Card>
  );
  await expect(page.locator('p')).toHaveText('This is inside the card.');
});

This allows for comprehensive testing of component composition and rendering logic.

When you need to test a component that relies on global browser APIs or browser-specific behavior, Playwright’s component testing shines. For instance, testing a component that uses window.localStorage or listens to resize events is straightforward. You can directly interact with these APIs as if you were in a real browser, and Playwright handles the browser context.

The real power comes when you realize that component tests are just as capable of testing asynchronous behavior and network requests as end-to-end tests. You can use page.route() to intercept network calls and mock responses, allowing you to test how your component behaves under various data conditions without needing a backend.

// src/components/DataFetcher.spec.js
import { test, expect } from '@playwright/experimental-ct-react';
import DataFetcher from './DataFetcher';

test('should display fetched data', async ({ mount, page }) => {
  await page.route('**/api/data', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ message: 'Hello from API!' }),
    });
  });

  await mount(<DataFetcher url="/api/data" />);
  await expect(page.locator('div')).toContainText('Hello from API!');
});

This allows you to test complex data fetching logic, loading states, and error handling in isolation, ensuring your component is robust.

The setup for Playwright Component Testing involves installing the necessary packages (@playwright/experimental-ct-react and playwright) and configuring Playwright. The configuration file (playwright-ct.config.js) specifies the framework adapter, the component test directory, and other settings.

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

export default defineConfig({
  // Use the React Server Components adapter.
  // See https://playwright.dev/docs/component-testing/react-server-components
  // adapter: '@playwright/experimental-ct-react/adapter', // For RSC
  // Use the React adapter.
  // See https://playwright.dev/docs/component-testing/react
  adapter: '@playwright/experimental-ct-react/adapter',

  // Set the root directory for your component tests.
  testDir: './src/components',

  // Maximum time for tests to run.
  // timeout: 10 * 1000, // 10 seconds

  // Use the specified browser to run the tests.
  // browser: {
  //   name: 'chromium',
  //   // Use headless: false to see the browser UI.
  //   headless: true,
  // },
});

The adapter option is crucial here, telling Playwright how to interpret and render your specific framework components.

When you write tests for components that depend on React Context, you can provide the context directly within the mount function. This is a powerful way to isolate context-dependent components and test their behavior without needing to set up the full application context.

// src/components/ThemedButton.spec.js
import { test, expect } from '@playwright/experimental-ct-react';
import { ThemeProvider, theme } from '../context/ThemeContext'; // Assuming you have a ThemeContext
import ThemedButton from './ThemedButton';

test('should render with theme', async ({ mount }) => {
  await mount(
    <ThemeProvider value={theme.dark}>
      <ThemedButton>Click Me</ThemedButton>
    </ThemeProvider>
  );
  await expect(page.locator('button')).toHaveCSS('background-color', theme.dark.backgroundColor);
});

This ability to inject providers directly into the test mount makes it incredibly easy to test components that are deeply nested within your application’s context hierarchy.

The next step after mastering component testing is to explore how Playwright handles interactions with custom event emitters or more complex state management patterns within your components.

Want structured learning?

Take the full Playwright course →