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

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. Three boundaries matter.

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) leak across specs and must be reset.

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.

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 — are out of scope and belong in end-to-end suites instead. Knowing which side of each boundary a behavior sits on is what keeps a CT suite both fast and meaningful.

Prerequisites

  • npm, pnpm, or yarn) on the runner
  • @playwright/experimental-ct-react installed and npx playwright install run
  • playwright/index.tsx mount entry file for global styles and providers
  • Testing Library best practices

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.

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'] } }],
});

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

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

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

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.

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.

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.

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. 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. The third is portal and overlay rendering — modals rendered into document.body sit outside the component locator, so query them through page instead. The fourth is animation timing; disable CSS transitions in your mount entry to stop a half-finished animation from racing an assertion. For genuinely intermittent cases, route them through a deliberate flaky-test mitigation workflow rather than blindly bumping retries, which only hides the underlying nondeterminism.

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.

# .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