QA How-To
Mount React Components in Playwright Step by Step
Learn to mount react components playwright step by step, configure the CT harness, test props and callbacks, and verify reliable browser behavior in 2026.
20 min read | 2,486 words
TL;DR
Install Playwright's React component-test package, create the browser harness, and call the mount fixture with JSX. Interact through accessible locators and verify visible state, callback values, updates, and cleanup with web-first assertions.
Key Takeaways
- Initialize the official React component-testing package and keep Playwright package versions aligned.
- Load global styles and required Vite aliases through the dedicated component-test harness.
- Call mount inside each test and use the returned root locator to keep assertions scoped.
- Test props, callbacks, rerenders, and cleanup through visible behavior rather than React internals.
- Prefer role and label locators because they express the component's accessible contract.
- Run tests repeatedly and in isolation before treating them as reliable pull-request checks.
To mount react components playwright step by step, initialize Playwright Component Testing for React, configure its Vite-backed browser harness, import a component into a .spec.tsx file, and pass JSX to the mount fixture. The returned component handle is a Playwright locator, so you can click, type, and assert against a real browser DOM.
This tutorial builds one small but production-shaped quantity picker. You will test its initial props, user interactions, callback output, prop updates, and cleanup. For the broader architecture, test-boundary advice, providers, network mocking, and CI strategy, read the Playwright Component Testing for React complete guide.
The examples use the official @playwright/experimental-ct-react package. The package name still signals that its component-specific API can change, so keep all Playwright dependencies aligned and review release notes when upgrading.
What You Will Build
You will create a QuantityPicker that behaves like a real reusable form control. By the end, you will have:
- A dedicated Playwright CT configuration and browser harness.
- A React component with accessible increment and decrement controls.
- Tests for initial props, clicks, keyboard focus, and disabled limits.
- A callback assertion that crosses from the browser component to the test worker.
- A prop-update and unmount test for lifecycle behavior.
- A repeatable command for local runs and debugging.
The finished test suite runs the component in Chromium without navigating to a deployed application. It exercises real rendering and events while keeping the scenario smaller than an end-to-end test.
Prerequisites
Use Node.js 20 or 22 LTS, npm, React 18 or newer, and TypeScript. Your project should already compile JSX. Run commands from the workspace that owns the React application.
Check your environment:
node --version
npm --version
Install component testing through the official initializer:
npm init playwright@latest -- --ct
Choose React and TypeScript when prompted. The initializer installs @playwright/experimental-ct-react, creates a component-test configuration, creates a playwright/ browser entry, and usually adds a package script. If Chromium was not installed, run:
npx playwright install chromium
Keep @playwright/experimental-ct-react, @playwright/test, and any direct playwright dependency on compatible versions selected by the initializer. Commit the lockfile so local machines and CI use the same runner and browser revision.
Step 1: Initialize the Playwright React Component Test Project
First, inspect the generated files. Exact names can vary slightly by initializer version, but a React setup should include a CT config, an HTML facade, and a TypeScript or TSX harness. A clear project layout is:
playwright-ct.config.ts
playwright/
index.html
index.tsx
src/
components/
package.json
Use a dedicated script so contributors do not confuse component and end-to-end projects:
{
"scripts": {
"test:ct": "playwright test -c playwright-ct.config.ts"
}
}
Create or confirm this configuration:
// playwright-ct.config.ts
import { defineConfig, devices } from '@playwright/experimental-ct-react';
export default defineConfig({
testDir: './src',
testMatch: '**/*.ct.spec.tsx',
fullyParallel: true,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
ctPort: 3100,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});
defineConfig comes from the React CT package, not the ordinary test package. testDir places tests beside components, while testMatch prevents ordinary unit or end-to-end specs from entering this project. Start with one browser so failures remain focused. Add browser projects only when cross-browser component behavior is a stated risk.
Verify the step: run npm run test:ct -- --list. The command should load the configuration without a missing-module or unknown-project error. With no specs yet, an empty test list is expected.
Step 2: Create the Browser Harness and Load Styles
The harness is the page in which Playwright mounts JSX. It is not your full application shell. Keep it deterministic and load only dependencies every mounted component genuinely needs.
Create the facade HTML:
<!-- playwright/index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Playwright Component Tests</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>
Then load application-wide styles from the harness:
// playwright/index.tsx
import '../src/index.css';
The CT integration supplies the mounting runtime. Do not call createRoot here. Global resets, design tokens, and fonts belong in this entry when all subjects rely on them. Component-specific CSS should remain imported by the component itself. Avoid initializing analytics, service workers, production error reporting, or real authentication because those side effects make isolated tests unpredictable.
If components use an alias such as @/, add only the required Vite settings:
// inside use in playwright-ct.config.ts
ctViteConfig: {
resolve: {
alias: { '@': new URL('./src', import.meta.url).pathname },
},
},
Do not copy a large production Vite configuration blindly. Server-only plugins and environment-dependent transforms can break the small browser bundle. Add each alias or plugin because a mounted component requires it.
Verify the step: rerun npm run test:ct -- --list. A valid harness should compile once a test imports a component. If the stylesheet path is wrong, Vite reports the unresolved import with the harness filename.
Step 3: Build an Accessible React Component to Mount
Create a controlled, user-visible example with enough behavior to justify a browser test:
// src/components/QuantityPicker.tsx
import { useEffect, useState } from 'react';
type QuantityPickerProps = {
initialValue: number;
min?: number;
max?: number;
onChange: (value: number) => void;
onDispose?: () => void;
};
export function QuantityPicker({
initialValue,
min = 0,
max = 10,
onChange,
onDispose,
}: QuantityPickerProps) {
const [value, setValue] = useState(initialValue);
useEffect(() => () => onDispose?.(), [onDispose]);
const commit = (next: number) => {
setValue(next);
onChange(next);
};
return (
<section aria-label="Quantity picker">
<button
type="button"
onClick={() => commit(value - 1)}
disabled={value <= min}
>
Decrease
</button>
<output aria-live="polite" aria-label="Current quantity">
{value}
</output>
<button
type="button"
onClick={() => commit(value + 1)}
disabled={value >= max}
>
Increase
</button>
</section>
);
}
The component exposes a public contract: initial value, allowed range, change callback, rendered output, and cleanup callback. Native buttons provide keyboard behavior and disabled semantics without custom event code. The labeled section gives tests a stable region, and the labeled output announces value changes to assistive technology.
This example intentionally avoids inspecting hook state or render counts. Users see buttons and a current quantity, so the test should operate those elements. The same principle applies to production components: assert the accessible result of behavior, not its implementation.
Verify the step: run your existing TypeScript or build command. It should accept the props and JSX with no implicit any errors. In an editor, removing the required onChange prop from a usage should produce a type error.
Step 4: Mount React Components Playwright Step by Step
Create the first component spec beside the implementation. Import test and expect from the React CT package so the mount fixture has the correct JSX types.
// src/components/QuantityPicker.ct.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { QuantityPicker } from './QuantityPicker';
test('renders the initial quantity and range controls', async ({ mount }) => {
const component = await mount(
<QuantityPicker
initialValue={2}
min={0}
max={3}
onChange={() => {}}
/>,
);
await expect(
component.getByRole('region', { name: 'Quantity picker' }),
).toBeVisible();
await expect(
component.getByRole('status', { name: 'Current quantity' }),
).toHaveText('2');
await expect(
component.getByRole('button', { name: 'Decrease' }),
).toBeEnabled();
await expect(
component.getByRole('button', { name: 'Increase' }),
).toBeEnabled();
});
mount renders the JSX into the CT facade and returns a locator for the mounted root. Scope queries through component when the element belongs inside that root. For portals such as dialogs, query from page because the portal may render outside the root.
Playwright locator assertions retry until their condition becomes true or the assertion times out. Do not add waitForTimeout before toHaveText. The assertion already waits for React to render and for the DOM to reach the requested state.
| Locator | Prefer it for | Avoid using instead |
|---|---|---|
getByRole |
Buttons, headings, regions, status output | CSS classes |
getByLabel |
Form controls with labels | Placeholder-only targeting |
getByText |
Unique visible copy | Deep DOM selectors |
getByTestId |
Non-semantic stable targets | Every ordinary control |
Verify the step: run npm run test:ct -- QuantityPicker.ct.spec.tsx --project=chromium. Expect one passing test. Open the HTML report with npx playwright show-report if the terminal reports a failure.
Step 5: Test Interactions, Limits, and Callback Props
Add a test that clicks the same control twice, observes the callback, and verifies the maximum boundary:
test('increments, emits values, and disables at the maximum', async ({ mount }) => {
const emitted: number[] = [];
const component = await mount(
<QuantityPicker
initialValue={1}
max={3}
onChange={(value) => emitted.push(value)}
/>,
);
const increase = component.getByRole('button', { name: 'Increase' });
const output = component.getByRole('status', { name: 'Current quantity' });
await increase.click();
await expect(output).toHaveText('2');
await expect.poll(() => emitted).toEqual([2]);
await increase.click();
await expect(output).toHaveText('3');
await expect(increase).toBeDisabled();
await expect.poll(() => emitted).toEqual([2, 3]);
});
A callback prop declared in the test is invoked through the component-testing bridge. expect.poll is useful because it repeatedly reads Node-side data until the callback result arrives. Keep callback payloads serializable and small. If the behavior represents an HTTP boundary instead, route the request from page rather than hiding the network behind an oversized callback mock.
Now cover the lower limit and keyboard focus:
test('starts disabled at the minimum and follows keyboard order', async ({ mount }) => {
const component = await mount(
<QuantityPicker initialValue={0} min={0} onChange={() => {}} />,
);
const decrease = component.getByRole('button', { name: 'Decrease' });
const increase = component.getByRole('button', { name: 'Increase' });
await expect(decrease).toBeDisabled();
await increase.focus();
await expect(increase).toBeFocused();
await increase.press('Enter');
await expect(
component.getByRole('status', { name: 'Current quantity' }),
).toHaveText('1');
});
These assertions verify browser behavior that a pure function test cannot represent as directly: native disabled state, focus, keyboard activation, DOM update, and accessible status.
Verify the step: run the file again. Expect three passing tests. Temporarily change max={3} to max={4} in the callback case and confirm the disabled assertion fails with the button state in the report. Restore the correct value.
Step 6: Update Props and Verify Unmount Cleanup
Mounting is only the start of a component lifecycle. Use the returned handle's update method when a parent would rerender the subject with new props. This checks that the rendered contract responds correctly without destroying the mounted tree.
test('updates range props on the mounted component', async ({ mount }) => {
const component = await mount(
<QuantityPicker initialValue={2} max={4} onChange={() => {}} />,
);
await expect(
component.getByRole('button', { name: 'Increase' }),
).toBeEnabled();
await component.update(
<QuantityPicker initialValue={2} max={2} onChange={() => {}} />,
);
await expect(
component.getByRole('button', { name: 'Increase' }),
).toBeDisabled();
});
React preserves the existing state because the component identity remains stable, but it receives the new max. That makes the increase control disabled. Use update for rerender behavior, not as a way to arrange every state directly. Important user transitions should still happen through clicks, typing, or resolved data.
Test observable cleanup with unmount:
test('calls the disposal callback when unmounted', async ({ mount }) => {
let disposals = 0;
const component = await mount(
<QuantityPicker
initialValue={1}
onChange={() => {}}
onDispose={() => { disposals += 1; }}
/>,
);
await component.unmount();
await expect.poll(() => disposals).toBe(1);
});
In production, cleanup risk often involves event listeners, observers, timers, or subscriptions. Assert a public symptom, such as no callback after removal, when possible. A dedicated cleanup callback is acceptable here because it makes the tutorial deterministic, but avoid exposing test-only internals in application APIs.
Verify the step: run npm run test:ct -- QuantityPicker.ct.spec.tsx --repeat-each=2. Expect ten total passes from five tests repeated twice. A stable result shows each case owns its mounted tree and callback data.
Step 7: Debug and Harden the Mounting Workflow
Run one test in Playwright UI mode while developing:
npm run test:ct -- --ui
Select the Chromium project and the quantity picker file. Use the action timeline, DOM snapshot, console, and network panels to understand failure state. For a headed terminal run, use:
npm run test:ct -- QuantityPicker.ct.spec.tsx --headed
A reliable component test creates all scenario state inside the test. Do not share a mutable emitted-values array at module scope. Do not depend on the order specs run. Register network routes before mount when an initial effect fetches data, and create a fresh query client or store for every mount.
Use web-first assertions such as toBeVisible, toBeDisabled, and toHaveText. Avoid fixed sleeps because they guess when React is ready and slow every successful run. When observing Node-side callback state, use expect.poll. When waiting for a browser request or event, start the Playwright wait before the action that triggers it.
Keep failure evidence proportionate to the suite. A trace on the first retry and a screenshot only on failure usually provide enough context without filling every run with artifacts. Read an intentional failure before enabling the suite as a required check. The report should identify the component, scenario, locator, expected state, and actual state clearly enough that another engineer can reproduce the problem locally. If it cannot, improve the test name or evidence while the example is still small.
The best layer depends on the risk:
| Risk | Best test layer | Reason |
|---|---|---|
| Value calculation | Unit | No browser behavior exists |
| Rendered control and interaction | Component | Real DOM with focused scope |
| Backend response contract | API or contract | Direct, precise feedback |
| Complete sign-in and checkout | End-to-end | Proves assembled services |
Verify the step: run npm run test:ct -- --project=chromium --repeat-each=3. All fifteen executions should pass. Then run a single test with -g "increments" and confirm only that named scenario executes.
Troubleshooting
mount is undefined or missing from fixture types -> Import test from @playwright/experimental-ct-react, keep the spec extension .tsx, and run it with the CT configuration rather than an end-to-end config.
The browser executable does not exist -> Run npx playwright install chromium. On Linux CI, use npx playwright install --with-deps chromium so required system libraries are present.
Vite cannot resolve an application import -> Add the required alias or browser-compatible plugin to ctViteConfig. Confirm filename casing because Linux CI is commonly stricter than a local macOS filesystem.
The component renders without production styling -> Import global CSS and design tokens from playwright/index.tsx. Confirm component CSS imports and static asset paths resolve through the CT Vite server.
A callback assertion times out -> Keep the callback payload serializable, perform the user action before reading it, and use expect.poll for worker-side values. First verify the UI state changed so you know the event handler ran.
A locator matches multiple elements -> Scope the locator to the mounted component or a named region and improve accessible names. Do not use .first() unless element order is itself the requirement.
Where To Go Next
You now know how to mount, interact with, update, and unmount a React component. Return to the complete Playwright Component Testing for React guide when you need help deciding which components and risks belong in this layer.
Most production components also need application context. Follow mock React context in Playwright component tests to add themes, authentication, routers, and fresh provider state without hiding the scenario. Then learn to test React error boundaries with Playwright so controlled render failures produce a useful fallback and recovery path.
When the local suite is stable, use the Playwright component tests with Vite and CI tutorial to align Vite settings, install browsers in automation, preserve reports, and create a dependable pull-request check. For locator design across component and end-to-end suites, review Playwright locator best practices.
Interview Questions and Answers
Q: What does the Playwright mount fixture return?
It returns a component handle that behaves as a locator for the mounted root and also supports component lifecycle operations such as update and unmount. Scope child locators through it unless the UI intentionally renders through a portal.
Q: Why import from @playwright/experimental-ct-react?
That package supplies the React-aware configuration and fixtures, including typed JSX mounting. Importing only from @playwright/test gives you ordinary browser fixtures but not the React mount fixture.
Q: How do callback props work in a component test?
Pass a function in the mounted JSX and record its serializable arguments in test-worker state. Trigger the callback through the UI, then use expect.poll to assert the worker-side result.
Q: When should a locator start from page instead of the component handle?
Start from page when React renders the target outside the mounted root, commonly through a portal. A modal dialog attached to document.body is a typical example.
Q: Why avoid fixed waits after mounting?
Playwright locator actions and assertions already retry around actionable and expected browser states. A fixed delay is slower and can still be too short under load.
Q: How do you keep component tests isolated?
Mount separately in every test, create fresh callback arrays, stores, and clients, and register routes within the scenario. Prove isolation with repeated and parallel runs.
Best Practices and Common Mistakes
- Mount the smallest tree that still expresses a user-recognizable behavior.
- Put important props beside the mount call so reviewers can understand the scenario.
- Prefer native HTML semantics and role or label locators.
- Scope ordinary queries to the returned component handle.
- Use
pagefor genuine portals and browser-wide behavior. - Assert a visible transition before asserting a callback side effect.
- Use
updateonly when prop rerendering is the production behavior. - Use
unmountto verify meaningful cleanup risks. - Never share mutable scenario state across tests.
- Never solve a race with
waitForTimeout. - Keep the CT package and browser binaries aligned through the lockfile.
- Retain API contract and end-to-end tests for risks mounting cannot prove.
Mount React Components Playwright Step by Step: Conclusion
To mount react components playwright step by step, configure the React CT runner, prepare a minimal browser harness, pass JSX to mount, and test through the returned root locator. The quantity picker suite proves initial props, accessible interaction, callback output, prop updates, and cleanup without launching the complete application.
Add one production component next. Choose a meaningful state transition, keep every dependency deterministic, run the test repeatedly, and inspect one intentional failure in UI mode. Once that test is trustworthy locally, connect the same command to CI while retaining broader tests for integration and release confidence.
Interview Questions and Answers
What is the difference between Playwright component testing and end-to-end testing?
Component testing mounts a focused React tree in a real browser and controls its inputs directly. End-to-end testing navigates the assembled application and validates integration across routes and services. I use component tests for concentrated UI states and keep end-to-end tests for critical complete journeys.
What does the mount fixture return in Playwright React CT?
It returns a component handle rooted at the mounted JSX. The handle supports locator operations plus lifecycle operations such as update and unmount. I scope ordinary assertions through that handle to prevent accidental matches elsewhere in the facade.
How would you verify a callback prop?
I pass a callback that records its serializable argument, operate the component through the UI, and first assert the visible result. Then I use expect.poll to verify the recorded worker-side value. This avoids relying on timing between the browser and test process.
Why use accessible locators in component tests?
Roles and labels express what a user or assistive technology can perceive. They survive CSS and wrapper refactors better than structural selectors. They also reveal missing names or incorrect semantics early in component development.
How do you test a portal mounted outside the component root?
I trigger it through the component handle, then locate the portal from page, such as page.getByRole('dialog'). I verify visible behavior and focus movement at the document level because that matches the production DOM structure.
How do you prevent flaky Playwright component tests?
I create all state per test, register routes before mounting, and use web-first assertions instead of sleeps. Stores, clients, callback records, and provider values are fresh for each scenario. I also repeat and parallelize the suite to expose hidden coupling before enabling CI enforcement.
Frequently Asked Questions
How do I mount a React component in Playwright?
Import test from @playwright/experimental-ct-react and receive the mount fixture in the test callback. Pass JSX to await mount(), then query and assert through the returned component handle.
Does Playwright component testing need a running application?
No. The component-test runner uses a Vite-backed facade to bundle and mount the imported component. It does not require a deployed application or a separate application dev server.
Can Playwright component tests pass callback props?
Yes. Pass a callback in JSX, trigger it through the rendered UI, and record serializable arguments in test-worker state. Use expect.poll when asserting the recorded values.
How do I rerender a mounted React component with new props?
Call update on the component handle with new JSX. Use this when production behavior involves a parent rerendering the same component with different props.
Which locators should I use in React component tests?
Prefer getByRole and getByLabel because they describe accessible behavior and tolerate markup refactoring. Scope them through the mounted component, except for portals rendered outside its root.
Why is Playwright React component testing called experimental?
The official package name is @playwright/experimental-ct-react, which indicates that component-specific integration APIs may change. Pin aligned Playwright versions, commit the lockfile, and review upgrade notes.
Related Guides
- Allure report in CI: Step by Step (2026)
- Flaky test quarantine in CI: Step by Step (2026)
- How to Use Playwright component testing react (2026)
- Inspect WebSocket Frames with Playwright Step by Step
- Parallel test sharding in CI: Step by Step (2026)
- Playwright Component Testing for React Complete Guide (2026)