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.
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, oryarn) on the runner@playwright/experimental-ct-reactinstalled andnpx playwright installrunplaywright/index.tsxmount 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
- Testing React Server Components with Playwright — where CT helps with async and server-rendered boundaries, and where a full end-to-end run is the right tool instead.
- Mocking network in Playwright component tests — using
page.route()to fulfill and abort responses, and why the Service Worker approach is blocked in CT.
Related
- Up to Component & Integration Testing — the parent discipline and architectural baseline.
- Testing Library best practices — semantic selector strategies that keep CT assertions stable.
- Vitest configuration & setup — the faster jsdom tier CT sits above.
- External service simulation — integration-tier stubbing patterns that complement browser routing.
- Flaky-test mitigation — handling intermittent failures without masking real defects.
Testing React Server Components with Playwright
React Server Components render on the server, so Playwright component testing can't mount them directly. Learn where CT helps, where E2E is required, and why.
Mocking Network in Playwright Component Tests
Use page.route and router.route to fulfill, modify, and abort responses in Playwright component tests, and learn why the MSW Service Worker is blocked in CT.