Playwright Component Testing

Playwright Component Testing (CT) mounts a single UI unit into a real Chromium, Firefox, or WebKit context and renders it through a Vite-powered sandbox, giving you genuine browser layout, real event dispatch, and authentic paint timing without paying for a full-page navigation. It occupies the middle band of the Component & Integration Testing discipline: heavier than a jsdom unit test, lighter than an end-to-end journey. This guide is the implementation reference for frontend and full-stack engineers, QA leads, and platform teams who need a deterministic mount-and-assert harness that survives parallel execution in CI. It covers the mounting architecture, the configuration surface of @playwright/experimental-ct-react, network control at the component boundary, and the failure modes that make CT runs flaky if you ignore them.

Playwright Component Testing mount pipeline A left-to-right flow showing a spec file feeding the mount call into a Vite sandbox, which renders the component into a real browser context where assertions and network routing run. spec.tsx mount(<C/>) Vite sandbox bundle + HMR Browser context real DOM + paint assertions expect() page.route() network stub
A spec mounts through the Vite sandbox into a real browser context where assertions and network stubs run.

Architectural Scope & Boundaries

CT is scoped to one rendered unit and the providers it strictly needs. The mount boundary is the contract: everything inside the mount() call is the system under test, and everything outside it — network, timers, navigation — is the test harness. This is the mental discipline that separates a CT suite that stays fast and legible from one that slowly accretes into a slow, flaky pseudo-end-to-end suite. Three boundaries matter, and every design decision in a CT spec is really a decision about which side of one of them a behavior belongs on.

First, the rendering boundary. mount() runs your component inside an iframe-isolated Vite bundle, so each spec gets a clean module graph. This means side effects that escape the component — global singletons, module-level fetch calls fired at import time, a store instantiated at the top level of a module — leak across specs and must be reset. The failure mode is subtle: because Vite caches the module graph within a worker, a singleton initialised during the first spec is silently reused by the second, so a test that passes in isolation fails when the file runs in order, or vice versa. The cure is to keep components free of import-time work and to construct any shared state inside a provider you mount explicitly, so its lifetime is bounded by the mount rather than by the module.

Second, the network boundary. Because the component executes in a real browser page, you intercept traffic with Playwright’s own page.route() rather than an in-process interceptor. The browser-based Mock Service Worker Service Worker is deliberately blocked in CT, which is why network control gets its own dedicated guide below. The important consequence for scoping is that the network is outside the mount boundary: the component’s fetch is part of the system under test, but the response it receives is supplied by the harness, so every test is responsible for stubbing the traffic its component will generate. An unstubbed request in CT does not fail cleanly the way an unhandled request does in a Node interceptor set to error — it leaves the real browser and reaches the live network, which is why a catch-all abort is a standard part of a disciplined CT setup.

Third, the navigation boundary. CT skips full-page navigation to stay fast, so APIs that depend on a real document load — window.location assignment, cross-origin cookies, multi-tab flows, service-worker-mediated caching — are out of scope and belong in end-to-end suites instead. This is the boundary teams most often violate by accident: a component that calls router.push() and expects a real URL change, or one that reads a cookie set by a prior navigation, is reaching for behavior the CT harness deliberately does not provide. Knowing which side of each boundary a behavior sits on is what keeps a CT suite both fast and meaningful; when a spec starts needing behavior from the far side of the navigation boundary, that is the signal to promote it to the end-to-end tier rather than to fight the harness.

The three boundaries around a mounted component The mounted component sits inside the rendering boundary; the network is stubbed at its edge, and navigation-dependent behavior is out of scope and belongs to end-to-end tests. rendering boundary (mount) component system under test providers it needs network boundary page.route() stubs navigation boundary out of scope → E2E everything inside mount is tested; the network is stubbed at its edge; navigation is out of scope
The mount boundary defines the system under test; the network is stubbed at its edge and navigation lives beyond it.

Prerequisites

Step-by-Step Implementation

Step 1: Install the adapter and scaffold the mount entry

npm install @playwright/experimental-ct-react --save-dev
npx playwright install --with-deps chromium

CT needs a mount entry file where global CSS and app-wide providers are registered. Keep it minimal so each spec controls its own context.

// playwright/index.tsx
import '../src/styles/global.css';
// Hooks like beforeMount can register global providers here if every spec needs them.

Resist the temptation to wrap every provider in this file. Anything registered here applies to every spec whether it needs it or not, which reintroduces exactly the hidden coupling the mount boundary exists to prevent — a theme provider that one component depends on becomes an invisible dependency of every other test in the suite. Keep the entry to genuinely global concerns: a CSS reset, design-token variables, a font preload. Everything component-specific belongs in the individual mount() call, where its presence is explicit and its scope is one test. The beforeMount hook is the escape hatch for the rare provider that truly is universal, such as an internationalisation context every component reads; use it sparingly and document why.

Step 2: Configure playwright-ct.config.ts

The ctViteConfig property is the bridge to your real bundler settings — path aliases, CSS module conventions, and asset handling must mirror production so the mounted component behaves identically.

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

export default defineConfig({
  testDir: './src',
  testMatch: '**/*.spec.tsx',
  fullyParallel: true,
  use: {
    trace: 'on-first-retry',
    ctViteConfig: {
      resolve: {
        alias: { '@': path.resolve(__dirname, './src') },
      },
      css: { modules: { localsConvention: 'camelCase' } },
    },
  },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});

The single most consequential line here is ctViteConfig. CT bundles your component with its own Vite instance, which does not automatically inherit your application’s vite.config.ts. If your production build resolves @/components through a path alias, transforms CSS modules with a particular naming convention, or injects define constants, and the CT config does not mirror those settings, the component will either fail to bundle or — more insidiously — bundle into something subtly different from what ships. A mismatch here produces failures that look like component bugs but are really build-configuration drift, so treat ctViteConfig as a contract that must track production. The trace: 'on-first-retry' setting is the other pragmatic default: it captures a full timeline and DOM snapshot only when a test fails and is retried, giving you a debuggable artifact for flakes without paying the storage and time cost of tracing every green run.

Step 3: Write a mount-and-assert spec with providers

Wrap the unit with only the providers it needs at the mount boundary so state propagation stays deterministic.

// src/UserCard.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { ThemeProvider } from './theme';
import { UserCard } from './UserCard';

test('renders user card with injected context', async ({ mount }) => {
  const component = await mount(
    <ThemeProvider mode="dark">
      <UserCard id="usr_992" />
    </ThemeProvider>,
  );

  await expect(component).toContainText('usr_992');
});

Notice that the providers are supplied at the mount site, not globally. ThemeProvider wraps only this component because only this test needs a specific theme, which keeps the state propagation explicit and the test self-documenting: a reader sees exactly which context values the assertion depends on without hunting through a shared setup file. This is the CT equivalent of the render-with-wrapper pattern familiar from Testing Library, and it pays the same dividend — when the test fails, the failure is local to the arrangement you can see. Keep the provider tree as shallow as the assertion allows; each extra provider is another collaborator whose behavior can influence the result, and a mount wrapped in five providers is quietly an integration test that has drifted past the single-unit scope CT is tuned for.

Step 4: Route network at the component boundary

Inject the page fixture and stub responses before the request fires. This pattern is expanded in the dedicated network guide.

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

test('renders dashboard from a stubbed API', async ({ mount, page }) => {
  await page.route('**/api/metrics', (route) =>
    route.fulfill({ json: { activeUsers: 42 } }),
  );

  const component = await mount(<Dashboard />);
  await expect(component).toContainText('42');
});

Order is the thing to get right here: the route must be registered before mount(), because the component’s data fetch fires synchronously inside its mount effect and a route added after the mount will miss that first request. If your assertions intermittently catch a loading state instead of resolved data, this ordering is almost always the cause. Because the route is stubbed rather than live, the response is byte-identical on every run and on every machine, which is precisely what makes the test deterministic — no dependency on a backend being up, no variance from real latency, no data that shifts between runs. The full vocabulary of fulfilling, rewriting, delaying, and aborting responses is the subject of the dedicated network guide linked below; the point for now is that network control is a first-class part of the mount recipe, not an afterthought.

Step 5: Drive interactions and update props

Use Playwright locators for events; use component.update() to re-render with new props without remounting.

test('increments on click and re-renders on prop change', async ({ mount }) => {
  const component = await mount(<Counter start={0} />);
  await component.getByRole('button', { name: 'Increment' }).click();
  await expect(component).toContainText('1');

  await component.update(<Counter start={10} />);
  await expect(component).toContainText('10');
});

Two distinct capabilities appear in this step. Interactions run through Playwright locators — getByRole(...).click() and friends — which dispatch real browser events, so a click here triggers the same event path a user’s click would, including focus changes and default actions that a synthetic jsdom event often skips. The component.update() call is the CT answer to re-rendering with new props: it re-runs React’s reconciliation against the already-mounted tree rather than tearing down and remounting, which lets you assert on prop-change behavior — a value transition, a useEffect firing on a dependency change — without losing the component’s internal state. Reach for update() when the behavior under test is specifically the response to a prop change; for an independent scenario, a fresh mount() in a separate test keeps the cases isolated.

Configuration Reference Table

Option Location Type Default Purpose
testDir config root string . Directory scanned for spec files.
testMatch config root string | RegExp **/*.spec.tsx Pattern that isolates CT specs from end-to-end suites.
ctViteConfig use InlineConfig {} Inline Vite config: aliases, CSS modules, plugins, define.
ctPort use number 3100 Port for the internal CT dev server.
ctTemplateDir use string playwright Folder holding index.html / index.tsx mount entry.
trace use 'on' | 'off' | 'on-first-retry' off Trace capture policy; on-first-retry keeps artifacts lean.
screenshot use 'on' | 'off' | 'only-on-failure' off Screenshot capture on failure for diagnostics.
serviceWorkers use.contextOptions 'allow' | 'block' allow Set block to stop a browser Service Worker from intercepting routes.
fullyParallel config root boolean false Runs specs in parallel across workers.
retries config root number 0 Per-spec retry budget; pair with quarantine to avoid masking bugs.
timeout config root number 30000 Per-test timeout in milliseconds.

A few of these options carry more weight than their terse defaults suggest. serviceWorkers: 'block' is not optional hygiene but a correctness requirement: leave it at allow and a browser Service Worker — a lingering MSW registration, a PWA cache — can intercept the very requests your page.route() handlers are trying to stub, producing races that present as flaky network assertions. fullyParallel is the lever that turns a slow suite fast, but it also means specs must not share mutable global state, so it should be turned on early while the suite is still small enough to fix the leaks it exposes. retries deserves the most caution: a non-zero retry budget hides nondeterminism rather than resolving it, so keep it at zero in local development where a flake should be felt, and reserve any CI retry for a deliberate quarantine workflow that counts flakes instead of silently absorbing them. Treat the table as a set of coupled decisions rather than independent knobs — parallelism, isolation, and retry policy together determine whether the suite is trustworthy or merely green.

Verification & Assertions

Anchor assertions to what the user perceives, not to internal component state. Prefer web-first matchers — toBeVisible(), toContainText(), toHaveCount() — because they auto-retry until the condition holds, eliminating the manual waits that cause flake. Query by role and accessible name first; fall back to data-testid only when no semantic handle exists.

The auto-retrying behavior of web-first matchers is worth understanding rather than taking on faith, because it is what makes a real-browser assertion stable despite the asynchrony a real browser introduces. When you write await expect(locator).toBeVisible(), Playwright polls the condition on a short interval until it holds or the timeout elapses, so a value that appears one animation frame after a click is caught without a hand-tuned waitFor. This is a categorical improvement over the manual sleep-and-assert pattern that plagues naive browser tests: you are not guessing how long to wait, you are declaring the end state and letting the runner converge on it. The corollary is that you should almost never introduce a fixed delay in a CT spec — a page.waitForTimeout() is a code smell that trades determinism for a magic number, and the web-first matcher it replaces is both faster on the happy path and more robust on a slow CI box.

test('verifies loaded state, not implementation detail', async ({ mount }) => {
  const component = await mount(<DataGrid rows={mockRows} />);

  await expect(component.getByRole('row')).toHaveCount(6); // header + 5 rows
  await expect(component.getByTestId('loading-spinner')).toBeHidden();
  await expect(component).toHaveText(/Initial State Loaded/);
});

For visual confidence, toHaveScreenshot() is available, but treat pixel diffs as a complement to behavioral assertions, not a replacement — a screenshot passes while logic silently breaks. The reason to lean on role-based queries over screenshots is the same reason they survive refactors: a query for getByRole('button', { name: /submit/i }) asserts a contract about what the component is to a user, whereas a pixel comparison asserts what it looks like down to the sub-pixel, which changes every time a designer nudges a margin. Screenshots earn their place for genuinely visual regressions — a chart’s rendering, a layout that CSS logic can break — but as the primary assertion they generate noisy diffs that reviewers learn to approve reflexively, which is worse than no test at all. A healthy CT spec reads as a sequence of user-observable facts: this control exists, this text appears, this count is correct, this element is hidden until the data resolves.

Edge Cases & Failure Modes

The most common failure is import-time side effects. If a module fires a fetch or instantiates a singleton at load, that call escapes the mount boundary and pollutes later specs; move such work into useEffect or a provider you mount explicitly. Because the Vite worker caches modules, the polluting side effect runs once and its residue is inherited by every subsequent spec in that worker, which is why these failures are order-dependent and maddening to reproduce — the offending test often passes alone and only fails when a particular predecessor ran first.

The second is unrouted requests: a real browser page will attempt the live network for any unstubbed URL, so add a catch-all route that aborts unexpected traffic and fail loudly. Without it, a forgotten endpoint does not error — it succeeds against production or hangs until timeout, and either outcome corrupts the run in a way that is far harder to diagnose than an explicit “unexpected request” failure would be.

The third is portal and overlay rendering — modals, tooltips, and toasts rendered into document.body via a React portal sit outside the component locator’s subtree, so component.getByRole('dialog') finds nothing. Query these through the page fixture instead, which sees the whole document; the mental rule is that the component locator is scoped to the mounted subtree while page is scoped to everything the browser rendered.

The fourth is animation timing; a CSS transition or an entrance animation can leave an element mid-flight when an assertion runs, producing a value that is correct a frame later but wrong at the instant of the check. Disable transitions globally in your mount entry — a small stylesheet that zeroes animation-duration and transition-duration under a test flag — so paint is instantaneous and assertions are not racing the compositor. For genuinely intermittent cases that survive these fixes, route them through a deliberate flaky-test mitigation workflow rather than blindly bumping retries, which only hides the underlying nondeterminism instead of removing it.

Performance & CI Impact

A CT spec typically runs in 200–400 ms — roughly an order of magnitude slower than a jsdom unit test but far cheaper than a full end-to-end journey. The dominant cost is the Vite bundle build, so warm the cache between runs and let fullyParallel spread specs across workers sized to the runner’s CPU count. Cache the Playwright browser binaries to skip the multi-hundred-megabyte download on every job. Enable trace: 'on-first-retry' and screenshot: 'only-on-failure' so artifacts are produced only when they earn their storage.

That order-of-magnitude gap in both directions is what makes CT a deliberate middle tier rather than a default. Because a jsdom unit test is roughly ten times faster, the base of the suite — pure logic, formatting, reducers, hooks that touch no real browser API — belongs there, and pushing that work into CT needlessly inflates pipeline time. Because a full end-to-end journey is roughly ten times slower again and depends on a running application and its services, the top of the suite stays small and reserved for genuine cross-page flows. CT earns its place for exactly the assertions the other two tiers cannot make truthfully: real layout and geometry, authentic event dispatch, true browser APIs like IntersectionObserver, and visual correctness — none of which jsdom can answer and none of which justify booting a whole application in an end-to-end run.

Per-test cost and fidelity across the three tiers A jsdom unit test runs in tens of milliseconds, a Playwright component test in a few hundred, and an end-to-end journey in seconds, with fidelity rising in the same order. Cost and fidelity rise together across tiers jsdom unit ~20–40 ms, logic only Playwright CT ~200–400 ms real DOM + paint end-to-end seconds, full app real services most tests this tier fewest tests
CT is the deliberate middle tier: an order of magnitude above jsdom in cost and fidelity, an order below full end-to-end.
# .github/workflows/component-tests.yml
name: Component Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test --config=playwright-ct.config.ts --project=chromium
        env:
          CI: true

Keep CT and end-to-end suites in separate configs so they never compete for browser instances on the same runner, and reserve heavier full-navigation coverage for the end-to-end tier where it belongs.

In-Depth Guides