Resource library

QA How-To

How to Use Playwright component testing react (2026)

Learn Playwright component testing React setup, mounting, providers, API mocking, visual checks, debugging, and CI patterns with runnable CI examples.

22 min read | 2,748 words

TL;DR

Playwright component testing React uses the experimental React component package to bundle a component with Vite, mount it in a real browser, and return a Locator. Install with the component-testing initializer, configure the harness, mount inside each test, and assert behavior through the rendered UI.

Key Takeaways

  • Use @playwright/experimental-ct-react and keep its version aligned with the Playwright packages in the repository.
  • Mount components in each test and interact with the returned locator through user-visible roles and labels.
  • Configure global CSS and browser-side providers through playwright/index.tsx and typed beforeMount hooks.
  • Mock HTTP boundaries with page.route while keeping component props and hook configuration serializable.
  • Test responsive layout, accessibility behavior, and cleanup in a real browser without duplicating end-to-end coverage.
  • Run component tests as a separate CI job with traces, screenshots, and explicit browser projects.

Playwright component testing React places an individual React component in a real browser, then lets a test use Playwright locators, actions, assertions, network routing, screenshots, and traces against it. In 2026 the official package is still named @playwright/experimental-ct-react, so teams should treat upgrades as reviewed framework changes while still using the feature for fast, browser-realistic feedback.

The strongest component tests sit between DOM-only unit tests and full end-to-end journeys. They render real CSS and browser behavior, but avoid the data setup, navigation, and service dependencies of an entire application. This guide builds a maintainable setup from installation through CI and explains the boundaries that experienced SDETs should defend.

TL;DR

npm init playwright@latest -- --ct
npm run test-ct
import { test, expect } from '@playwright/experimental-ct-react';
import { SaveButton } from './SaveButton';

test('disables saving while a request is pending', async ({ mount }) => {
  const component = await mount(<SaveButton pending onSave={() => {}} />);

  await expect(component.getByRole('button', { name: 'Saving...' }))
    .toBeDisabled();
});
Need Best layer Reason
Pure calculation Unit test No browser or render is needed
Component behavior with real layout Playwright component test Fast isolation with browser semantics
Several services and screens End-to-end test Validates integrated user workflow
API schema and rules API or contract test More direct failure signal

1. What Playwright Component Testing React Actually Runs

Playwright component testing does not open a deployed page and search for a component inside the whole application. The runner discovers imported components, builds a browser bundle with Vite, serves a facade page, and renders the selected React tree into a root element. The test itself runs in the Node.js worker while the component runs in the browser. The mount fixture coordinates those two environments and returns a Playwright Locator representing the mounted root.

That split explains both the power and the constraints. The component receives real browser events, layout, fonts, media queries, focus behavior, and network calls. The test can read files, create serializable data, use fixtures, and attach artifacts. However, a Node object cannot simply become a live browser object. Props and hook configuration must cross a serialization boundary, while callback invocations are marshalled back to the test process.

The feature wraps Playwright Test, so familiar capabilities remain available: projects, retries, reporters, traces, screenshots, parallel execution, page, context, and web-first assertions. The package is experimental because its component-specific integration can change independently of the stable end-to-end API. Pin the version, review release notes, and update the runner package and browser binaries together.

A component test should observe the public rendered contract. Do not try to retrieve a React component instance or call internal methods. Click the button, type into the field, change props through the supported component handle, and assert visible or accessible outcomes. That produces a test that survives refactoring from hooks to reducers or from one internal component tree to another.

2. Install and Configure Playwright Component Testing React

For an existing React repository, the official initializer is the safest starting point because it installs the component package and creates the expected harness files. Run it from the application workspace that owns React and its styles.

npm init playwright@latest -- --ct

The generated files normally include playwright/index.html, playwright/index.tsx or a similar entry, a component-test configuration, and an npm command such as test-ct. The HTML must contain the mount root and load the entry module. A minimal version is:

<html lang='en'>
  <body>
    <div id='root'></div>
    <script type='module' src='./index.tsx'></script>
  </body>
</html>

Keep the component runner version aligned rather than mixing unrelated versions of @playwright/test, playwright, and @playwright/experimental-ct-react. After dependency changes, install the required browser binaries with the Playwright CLI used by that workspace.

A practical configuration separates component specs and retains useful evidence without recording every successful run:

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

export default defineConfig({
  testDir: './src',
  testMatch: '**/*.ct.spec.tsx',
  fullyParallel: true,
  reporter: [['html', { open: 'never' }], ['list']],
  use: {
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    ctPort: 3100,
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
});

Start with Chromium for pull-request speed, then add Firefox or WebKit only where component risks justify the cost. Browser diversity is valuable for CSS, form controls, and browser APIs, but multiplying every trivial component case rarely improves release confidence.

3. Build the Browser Harness and Load Application Styles

A component can render incorrectly even when its JSX is correct because production applications rely on global CSS, fonts, resets, portals, themes, and providers. The Playwright harness is the browser entry for those dependencies. Import global styles in playwright/index.tsx, not separately in every spec.

import '../src/index.css';
import '../src/styles/tokens.css';

If the application uses CSS Modules, keep standard *.module.css naming so the Vite-based component bundle recognizes the imports. If production Vite aliases or plugins are essential, copy the relevant high-level settings into ctViteConfig. Playwright component testing does not automatically reuse every setting in the application's Vite configuration. Avoid copying a large production config blindly, because server-only plugins and build transformations can make the isolated harness fragile.

import { defineConfig } from '@playwright/experimental-ct-react';
import react from '@vitejs/plugin-react';
import { resolve } from 'node:path';

export default defineConfig({
  use: {
    ctViteConfig: {
      plugins: [react()],
      resolve: {
        alias: { '@': resolve(process.cwd(), 'src') },
      },
    },
  },
});

When specifying plugins yourself, include the React plugin needed by the bundle. Also verify that static assets resolve from the same paths the component expects. A passing component with missing fonts or icons is a weak signal for visual behavior.

Keep harness code deterministic. Do not initialize production analytics, service workers, real payment SDKs, or error-reporting clients unless the test specifically owns that integration. Replace them with injectable adapters or controlled browser-side setup. The harness should recreate the component's necessary environment, not boot every application side effect.

For foundational selector strategy after the component is mounted, use the Playwright getByRole guide and treat accessibility semantics as part of the component contract.

4. Mount Components and Assert User-Visible Behavior

Mount inside each test so the initial state is visible beside the assertions. The returned component locator can be scoped with getByRole, getByLabel, getByText, and other locator methods. Prefer user-facing locators because component tests are an efficient place to catch missing labels, incorrect roles, and inaccessible names.

Consider a real React component:

type QuantityPickerProps = {
  initial: number;
  onChange: (value: number) => void;
};

export function QuantityPicker({ initial, onChange }: QuantityPickerProps) {
  const [value, setValue] = React.useState(initial);

  const change = (next: number) => {
    setValue(next);
    onChange(next);
  };

  return (
    <section aria-label='Quantity'>
      <button type='button' onClick={() => change(value - 1)}>Decrease</button>
      <output aria-live='polite'>{value}</output>
      <button type='button' onClick={() => change(value + 1)}>Increase</button>
    </section>
  );
}

The test interacts with the public UI and also verifies the callback contract:

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

test('increments the quantity and emits the new value', async ({ mount }) => {
  let emitted: number | undefined;
  const component = await mount(
    <QuantityPicker initial={1} onChange={value => { emitted = value; }} />,
  );

  await component.getByRole('button', { name: 'Increase' }).click();
  await expect(component.getByRole('status')).toHaveText('2');
  await expect.poll(() => emitted).toBe(2);
});

output has an implicit status role, so the locator expresses the live result. expect.poll safely observes the Node-side variable changed by the callback. Avoid fixed waits. Playwright actions already perform actionability checks, and locator assertions retry until the expected browser state appears.

5. Test Props, Children, Callbacks, Updates, and Unmounting

React components are contracts over inputs, output markup, events, and lifecycle. Cover meaningful equivalence classes rather than every prop combination. For a status banner, test one representative state for each semantic branch, such as success, warning, and failure, instead of snapshotting dozens of cosmetic variants.

Children are passed through ordinary JSX. Callback props can be declared inline and observed from the worker. When a parent would change props without destroying the child, use the component handle's update method to test the transition.

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

test('updates from trial to active without stale messaging', async ({ mount }) => {
  const component = await mount(
    <SubscriptionBanner status='trial'>Manage plan</SubscriptionBanner>,
  );

  await expect(component).toContainText('Trial');

  await component.update(
    <SubscriptionBanner status='active'>Manage plan</SubscriptionBanner>,
  );

  await expect(component).toContainText('Active');
  await expect(component).not.toContainText('Trial');
  await expect(component.getByText('Manage plan')).toBeVisible();
});

Use unmount() when cleanup is the behavior at risk. A component that registers a global event listener, starts a timer, or creates an observer should release it when removed. The public symptom might be that no callback fires after unmount, or that a browser-owned resource is disconnected.

test('removes its global shortcut during unmount', async ({ page, mount }) => {
  let saves = 0;
  const component = await mount(<Editor onSave={() => { saves += 1; }} />);

  await page.keyboard.press('Control+S');
  await expect.poll(() => saves).toBe(1);

  await component.unmount();
  await page.keyboard.press('Control+S');
  await expect.poll(() => saves).toBe(1);
});

Choose the platform-specific shortcut deliberately if the suite runs on macOS and other operating systems. The key lesson is to verify observable cleanup, not private hook invocation.

6. Add Routers, Themes, State, and Other Providers

Repeatedly wrapping every mounted component in five providers obscures the scenario. Component hooks let the browser harness apply providers before mount while each test passes small, serializable configuration. Define a HooksConfig type in the harness so tests and setup agree on the contract.

import { beforeMount } from '@playwright/experimental-ct-react/hooks';
import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from '../src/theme/ThemeProvider';

export type HooksConfig = {
  route?: string;
  theme?: 'light' | 'dark';
};

beforeMount<HooksConfig>(async ({ App, hooksConfig }) => {
  const route = hooksConfig?.route ?? '/';
  const theme = hooksConfig?.theme ?? 'light';

  return (
    <MemoryRouter initialEntries={[route]}>
      <ThemeProvider initialTheme={theme}>{App}</ThemeProvider>
    </MemoryRouter>
  );
});

A test supplies only the state it needs:

import type { HooksConfig } from '../playwright';

test('marks the billing route as current', async ({ mount }) => {
  const component = await mount<HooksConfig>(<AccountNavigation />, {
    hooksConfig: { route: '/account/billing', theme: 'dark' },
  });

  await expect(component.getByRole('link', { name: 'Billing' }))
    .toHaveAttribute('aria-current', 'page');
});

Use production providers when they are lightweight and deterministic. For data stores, prefer a documented factory that creates fresh state per mount. Never share a mutable singleton between parallel tests. Reset local storage, cookies, global registries, and browser mocks when a component depends on them, because component testing may reuse browser infrastructure as an optimization even though tests should remain functionally isolated.

Module mocks created in the Node test process do not automatically replace modules imported into the browser bundle. Injection, hook configuration, request routing, and small story components are clearer boundaries. This distinction prevents hours of debugging a mock that never affected browser code.

7. Mock API Calls at the Network Boundary

When a component fetches data, page.route can fulfill a request with a deterministic response. Register the route before mounting if the component requests data during its initial effect. Match a precise URL or pattern, validate the request when useful, and return realistic status, headers, and JSON.

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

test('renders an order returned by the API', async ({ page, mount }) => {
  await page.route('**/api/orders/ORD-42', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        id: 'ORD-42',
        total: 84.5,
        currency: 'USD',
        status: 'confirmed',
      }),
    });
  });

  const component = await mount(<OrderSummary orderId='ORD-42' />);

  await expect(component.getByRole('heading', { name: 'Order ORD-42' }))
    .toBeVisible();
  await expect(component.getByText('$84.50')).toBeVisible();
  await expect(component.getByText('Confirmed')).toBeVisible();
});

Write separate cases for loading, empty, malformed, unauthorized, and retry states according to product risk. Do not add fixed latency merely to see a spinner. Use a promise-controlled route only when the intermediate state itself matters, then release the response after asserting the loading UI.

A routed response proves how the component handles a defined contract, not whether the deployed backend actually honors that contract. Preserve API and end-to-end tests for integration confidence. The API error handling and negative testing guide helps choose failure cases that belong below the UI.

Unroute broad handlers when a test installs them outside fresh fixtures, and avoid accidental matches against source maps or unrelated endpoints. Precise routes produce clearer failures and make parallel behavior easier to reason about.

8. Cover Responsive, Accessible, and Visual Behavior

A real browser makes component testing especially useful for layout-dependent behavior. Set a viewport at the describe level or with test.use, then assert the product behavior caused by the breakpoint. Do not assert an implementation detail such as a Tailwind class if the user-visible contract is that a menu button replaces desktop navigation.

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

test.use({ viewport: { width: 390, height: 844 } });

test('uses mobile navigation at a narrow viewport', async ({ mount }) => {
  const component = await mount(<Header signedInUser='Asha' />);

  await expect(component.getByRole('button', { name: 'Open menu' }))
    .toBeVisible();
  await expect(component.getByRole('navigation', { name: 'Primary' }))
    .toBeHidden();
});

For dark mode, reduced motion, forced colors, or print styling, configure context options or call page.emulateMedia. Focus on branches the component actually implements. A useful accessibility test covers keyboard order, focus movement, accessible names, and state attributes. Automated accessibility scanners can supplement these assertions, but they do not prove a workflow is understandable.

Visual comparison is appropriate for high-value stable components such as charts, invoices, editors, and design-system primitives. Stabilize fonts, animation, time, and data before calling toHaveScreenshot. Keep the snapshot small by targeting the component locator. Review baseline changes as product changes, never update them automatically just to make CI green.

test('matches the compact invoice layout', async ({ mount }) => {
  const component = await mount(<InvoiceCard invoice={fixtureInvoice} />);
  await expect(component).toHaveScreenshot('invoice-card-compact.png', {
    animations: 'disabled',
  });
});

For deeper image strategy, see Playwright visual regression testing.

9. Debug Failures and Run Component Tests in CI

A component failure can originate in the spec, browser bundle, harness, network mock, CSS, or component itself. Start with Playwright UI mode or a headed single test. Inspect the locator, browser console, network activity, and trace before adding waits. A build-time error often points to an alias, unsupported plugin, CSS Module name, or browser-incompatible import. A runtime timeout often means the expected state never became visible.

npm run test-ct -- --ui
npm run test-ct -- src/components/OrderSummary.ct.spec.tsx --project=chromium

Keep CI separate from the end-to-end job so the team can identify the failed layer immediately. Install dependencies from the lockfile, install matching Playwright browsers, run the component command, and upload the report or test-results directory even on failure. A generic workflow step looks like this:

- name: Install dependencies
  run: npm ci
- name: Install Playwright Chromium
  run: npx playwright install --with-deps chromium
- name: Run React component tests
  run: npm run test-ct -- --project=chromium

Cache npm downloads according to the CI platform, but do not reuse an unkeyed browser cache across Playwright versions. Pin the Node runtime used by local development and CI. If tests shard, verify every shard can start or reach its own component server port without collision.

Use traces on retry or failure rather than for every local pass. Keep first-attempt failures visible even when retries recover them. A flaky component test usually reveals shared state, uncontrolled time, an imprecise network route, unstable animation, or an assertion on implementation timing. Quarantine only with an owner and expiry.

10. Design a Sustainable Playwright Component Testing React Strategy

Playwright component testing React should reduce risk at the component boundary, not become a smaller copy of the end-to-end suite. Prioritize components with meaningful interaction, browser behavior, complex state, responsive layouts, accessible widgets, or expensive end-to-end setup. Leave pure functions and simple conditional markup to fast unit tests. Leave authentication redirects, database persistence, and cross-service workflows to integration and end-to-end coverage.

A useful portfolio might include many unit tests, a focused set of component tests, API and contract checks, and a smaller set of critical browser journeys. The exact counts depend on risk, but ownership must be explicit. Frontend engineers should be able to run and repair component tests. QA engineers can provide scenario design, negative cases, browser expertise, and framework governance without becoming the only maintainers.

Use test names that state behavior and condition: announces validation after blur, moves focus into the opened dialog, or retains edits when the server rejects save. Keep fixtures small enough to read in the test. Build story wrappers only for boundaries that cannot cross between Node and browser, such as browser-native objects or complex application services.

Review upgrades because the component package remains experimental. Run a representative canary branch, examine release notes, regenerate snapshots only after visual review, and keep a rollback path through the lockfile. Do not postpone all component testing because of the label, but do not promise stable APIs without an upgrade policy.

Use Playwright fixtures and reusable setup to standardize data and diagnostics, while keeping the mount and scenario visible in each test.

Interview Questions and Answers

Q: How is a Playwright React component test different from an end-to-end test?

A component test bundles and mounts a selected React tree in a browser facade, so it isolates rendering and interaction from the full deployed application. An end-to-end test navigates the integrated system and validates several application boundaries. I use component tests for browser-realistic UI states and end-to-end tests for critical integrated journeys.

Q: Why does Playwright component testing use a Node and browser boundary?

The Playwright test worker runs in Node, while React renders in the browser bundle. This enables Node fixtures and browser interactions together, but it means props and hook configuration must be serializable. Browser-native or complex live objects need a story wrapper or injection within the browser environment.

Q: Where should React providers be configured?

Global styles belong in the Playwright browser entry. Reusable providers can be applied in a typed beforeMount hook, with each test passing small values through hooksConfig. I create fresh provider state per mount and avoid mutable global stores.

Q: How do you mock a component's API request?

I register page.route before the request can fire, usually before mounting. The handler fulfills a precise endpoint with a realistic response, and the test asserts the component's user-visible result. I keep separate API or end-to-end tests because a mocked response cannot prove service integration.

Q: What should a component test locate and assert?

It should locate controls by role, label, text, or a deliberate test ID and assert visible, accessible behavior. It should not access React internals or merely snapshot a large tree. Web-first locator assertions make asynchronous rendering reliable.

Q: When would you use update and unmount?

I use update when the risk is how a mounted component responds to changed parent inputs. I use unmount when cleanup matters, such as removing listeners, timers, or observers. Both tests should verify an external effect rather than implementation calls.

Q: What are the main CI risks for component tests?

Version mismatch, missing browser binaries, nondeterministic assets, port conflicts, and shared browser state are common risks. I pin dependencies, install matching browsers, separate the job, retain traces on failure, and ensure every test owns fresh state.

Common Mistakes

  • Importing test from @playwright/test and expecting the React mount fixture to exist.
  • Assuming the component package automatically reuses every production Vite plugin and alias.
  • Creating Node-side module mocks and expecting them to replace browser-bundled imports.
  • Mounting one mutable component in beforeAll and sharing it across tests.
  • Passing database clients, browser objects, or other nonserializable values as component props.
  • Testing private component methods instead of rendered behavior.
  • Registering a route after a mount effect already sent the request.
  • Updating visual baselines without reviewing the actual UI change.
  • Duplicating every component scenario in the end-to-end suite.
  • Treating the experimental package label as permission to leave upgrades ungoverned.

Conclusion

Playwright component testing React is most effective when it provides a real-browser check of an isolated, public UI contract. Install the official component runner, build a minimal faithful harness, mount per test, route external boundaries deliberately, and assert through user-visible semantics.

Start with one interaction-heavy component that is costly to cover end to end. Add its loading, success, failure, responsive, and keyboard states, run the suite as a distinct CI signal, and use what you learn to define the boundary for the rest of the repository.

Interview Questions and Answers

Explain the architecture of Playwright component testing for React.

Playwright uses Vite to bundle imported React components and serves a facade page that mounts them in a real browser. The test runs in a Node worker, while the component runs in the browser. The `mount` fixture coordinates the boundary and returns a Locator for the rendered root.

Why can some values not be passed directly to a mounted component?

The value must cross from the Node test process to the browser bundle. Plain serializable data works, and callbacks are marshalled, but complex live Node or browser objects do not preserve their identity across that boundary. I use a browser-side wrapper or provider for those dependencies.

How would you test a component that depends on React Router?

I wrap the mounted application in a MemoryRouter through a typed `beforeMount` hook. The test supplies its initial route through `hooksConfig` and asserts navigation through accessible links and visible route output. Each test receives fresh router state.

How do you decide between a unit, component, and end-to-end test?

I use a unit test for isolated logic, a component test for a rendered UI contract that benefits from real browser behavior, and an end-to-end test for integrated workflows across screens and services. I place the scenario at the lowest layer that proves the risk without mocking the behavior being validated.

How do you make React component tests reliable?

I mount per test, create fresh provider state, route network calls before mount, and use locators with web-first assertions. I control time, animation, and fixture data when they affect output. Traces and browser console evidence help diagnose failures without adding fixed waits.

What does the component handle support after mounting?

It acts as the root Locator for normal interactions and assertions. Component testing also adds supported operations such as `update` for changing the mounted React tree and `unmount` for removing it. I use those operations only when input transitions or cleanup are part of the public behavior.

How would you govern an experimental test package in CI?

I pin package versions, keep Playwright dependencies aligned, and test upgrades on a representative branch. The CI job installs matching browser binaries and retains diagnostic artifacts. A documented owner reviews release notes, snapshot changes, and rollback through the lockfile.

Frequently Asked Questions

Is Playwright component testing for React stable in 2026?

The official package is still named @playwright/experimental-ct-react. It is useful for production test suites, but teams should pin versions, review component-testing release notes, and validate upgrades before merging them.

How do I install Playwright component testing in a React project?

Run `npm init playwright@latest -- --ct` in the React workspace and select the relevant options. The initializer adds the component runner, harness files, configuration, and a component-test command.

Does Playwright component testing use a real browser?

Yes. The React component is bundled and rendered in a real browser page, so CSS, layout, focus, browser events, and media queries participate. The test worker itself runs in Node.js.

Can I use React Testing Library with Playwright component testing?

The tools can coexist, but a Playwright component test normally uses Playwright locators and web-first assertions. Keep DOM-focused unit tests where they are efficient, and use Playwright when real browser behavior or unified diagnostics adds value.

How do I add a router or theme provider to mounted components?

Use `beforeMount` from the React component hooks package and wrap `App` with the providers. Pass serializable per-test settings through typed `hooksConfig` so each mount starts from explicit state.

Can Playwright component tests mock API calls?

Yes. Register `page.route` before mounting and fulfill the endpoint with a controlled response. This tests component behavior against that response but does not replace service integration coverage.

Should component tests run in every Playwright browser?

Not necessarily. Run broad behavior quickly in one browser, then add Firefox or WebKit coverage for components with browser-specific layout, controls, or APIs. Choose the matrix by risk rather than multiplying every case.

Related Guides