For years, React interface component testing relied on the combination of Vitest/Jest and React Testing Library (RTL) running inside synthetic virtual DOM environments (jsdom or happy-dom) within Node.js.
Historically, this compromise balanced execution speed against fidelity. Full browser instances incurred heavy startup overhead, whereas jsdom offered a lightweight memory simulation of the DOM tree. In modern web architectures utilizing React 19, Tailwind CSS v4, container queries, and sophisticated CSS Grid systems, this compromise breaks down due to insurmountable technical limitations in jsdom:
jsdom performs no geometric computations. Invocations of getBoundingClientRect(), offsetWidth, and clientHeight return zeroes or crude hardcoded heuristics. It cannot verify whether a floating modal occludes an actionable button, whether text truncates via text-overflow: ellipsis, or whether position: sticky pins an element.jsdom does not parse or apply Flexbox layouts, CSS Grid tracks, container queries (@container), or dynamic CSS variables (var(--color-primary)).:hover, :active, and :focus-visible lack native pointer device backing in jsdom. Real physical cursor trajectories or coordinate-based clicks cannot be evaluated.jsdom is not a browser. Rendering quirks in WebKit (Safari) or compositing nuances in Gecko (Firefox) are completely absent.+-----------------------------------------------------------------------------+
| Limitations of jsdom / happy-dom Environments |
| |
| Node.js Memory -> Syntax HTML Parser -> Synthetic Node Tree |
| [Layout = 0px] [CSS Grid = Ignored] [Fonts / GPU Acceleration = None] |
+-----------------------------------------------------------------------------+
+-----------------------------------------------------------------------------+
| Playwright Component Testing (CT) in Real Browsers |
| |
| Test Runner (Node.js) |
| │ |
| ▼ (mount(<Button variant="primary" />)) |
| Local Vite CT Bundler |
| │ (Compiles isolated component + Tailwind v4 CSS) |
| ▼ |
| Real Browser Engine (Chromium / Firefox / WebKit) |
| │ |
| ├──► 100% Real Layout Engine: CSS Grid, Flexbox, BoundingBox, z-index |
| ├──► Native Input Stack: mousemove, hover, drag-and-drop, tab-focus |
| └──► Hardware-accelerated text rendering and computed CSS evaluation |
+-----------------------------------------------------------------------------+Playwright Component Testing (CT) solves this limitation, uniting the execution velocity of isolated component tests with the authentic fidelity of real browser engines.
In Playwright CT, a component is mounted in complete isolation from the backend and application routing tree, yet executes inside an authentic browser engine process.
+-----------------------------------------------------------------------------+
| Node.js Test Runner Process |
| test('renders active card interaction', async ({ mount }) => { ... |
+-----------------------------------------------------------------------------+
│
▼ (Mounting Command)
+-----------------------------------------------------------------------------+
| Vite CT Dev Server |
| - Host Page: playwright/index.html |
| - Design System Injection: playwright/index.tsx (Tailwind v4) |
| - Dynamic On-Demand Component Bundling |
+-----------------------------------------------------------------------------+
│
▼ (WebSocket CDP / WebDriver BiDi)
+-----------------------------------------------------------------------------+
| Real Browser Process (Chromium / WebKit / Firefox) |
| |
| +---------------------------------------------------------------------+ |
| | HTML Shell (playwright/index.html) | |
| | <div id="root"> | |
| | <!-- Mounted React 19 Component --> | |
| | <UserProfileCard user={mockUser} /> | |
| | </div> | |
| +---------------------------------------------------------------------+ |
| │ |
| ▼ |
| Geometric Computation (BoundingBox) -> Computed CSS Assertions |
+-----------------------------------------------------------------------------+playwright-ct.config.ts#import { defineConfig, devices } from '@playwright/experimental-ct-react';
import path from 'path';
export default defineConfig({
testDir: './src',
testMatch: /.*\.ct\.(ts|tsx)$/,
timeout: 15_000,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
use: {
/* Port for the local Vite component mounting server */
ctPort: 3100,
/* Vite bundling configuration */
ctViteConfig: {
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
},
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
});playwright/index.tsx#The host entrypoint configures the runtime environment, imports global Tailwind CSS v4 stylesheets, and provides lifecycle hooks:
import '@/app/globals.css';
import { beforeMount, afterMount } from '@playwright/experimental-ct-react/hooks';
// Register global component lifecycle hooks
beforeMount(async ({ App }) => {
// Enforce dark mode on the root container for design system validation
document.documentElement.classList.add('dark');
return <App />;
});In jsdom, testing that a table header maintains a fixed position under scrolling is impossible. In Playwright CT, real position: sticky behavior can be deterministically asserted:
import React from 'react';
import { test, expect } from '@playwright/experimental-ct-react';
import { DataTable } from './DataTable';
const MOCK_ROWS = Array.from({ length: 40 }, (_, i) => ({
id: `row-${i}`,
name: `Catalog Element #${i}`,
value: `$${(i + 1) * 10}.00`,
}));
test.describe('<DataTable /> Sticky Layout in Real Browser', () => {
test('table header retains fixed viewport coordinate under vertical scroll', async ({
mount,
page,
}) => {
// Mount component inside an explicit scrollable container
const component = await mount(
<div className="h-[300px] overflow-y-auto border border-border" id="scroll-container">
<DataTable data={MOCK_ROWS} />
</div>
);
const header = component.getByRole('rowheader', { name: 'Item Name' });
// Capture initial geometric bounding box relative to viewport
const initialBox = await header.boundingBox();
expect(initialBox).not.toBeNull();
const initialY = initialBox!.y;
// Scroll parent container down 500 pixels
await page.locator('#scroll-container').evaluate((el) => {
el.scrollTop = 500;
});
// Confirm header remained at original Y coordinate via position: sticky
const scrolledBox = await header.boundingBox();
expect(scrolledBox).not.toBeNull();
expect(scrolledBox!.y).toBeCloseTo(initialY, 1);
// Validate computed CSS rule
await expect(header).toHaveCSS('position', 'sticky');
});
});:hover)#Validating interactive visual transitions under real cursor hovering:
import React from 'react';
import { test, expect } from '@playwright/experimental-ct-react';
import { ActionButton } from './ActionButton';
test.describe('<ActionButton /> Interactive Pseudo-classes', () => {
test('button applies calculated Tailwind v4 styles on hover interaction', async ({ mount }) => {
let clickCount = 0;
const component = await mount(
<ActionButton
variant="accent"
onClick={() => {
clickCount += 1;
}}
>
Publish Lesson
</ActionButton>
);
// 1. Assert initial baseline styles
await expect(component).toHaveCSS('cursor', 'pointer');
// 2. Dispatch physical pointer hover over the button
await component.hover();
// 3. Confirm authentic browser engine applied transition and hover styles
await expect(component).toHaveCSS('transition-property', /background-color|all/);
// 4. Dispatch physical click event
await component.click();
expect(clickCount).toBe(1);
});
});Validating dropdown accessibility compliance driven entirely via keyboard inputs:
import React from 'react';
import { test, expect } from '@playwright/experimental-ct-react';
import { DropdownMenu } from './DropdownMenu';
test('navigates dropdown menu items using keyboard arrow keys', async ({
mount,
page,
}) => {
const component = await mount(
<DropdownMenu
triggerLabel="Actions"
items={[
{ id: 'edit', label: 'Edit Record' },
{ id: 'duplicate', label: 'Duplicate Entry' },
{ id: 'delete', label: 'Delete' },
]}
/>
);
const trigger = component.getByRole('button', { name: 'Actions' });
// Move keyboard focus to trigger button via Tab
await page.keyboard.press('Tab');
await expect(trigger).toBeFocused();
// Open menu via Enter key
await page.keyboard.press('Enter');
// Verify menu popover is visible
const menuList = page.getByRole('menu');
await expect(menuList).toBeVisible();
// Navigate to first item via ArrowDown
await page.keyboard.press('ArrowDown');
const firstItem = page.getByRole('menuitem', { name: 'Edit Record' });
await expect(firstItem).toBeFocused();
// Navigate to second item via ArrowDown
await page.keyboard.press('ArrowDown');
const secondItem = page.getByRole('menuitem', { name: 'Duplicate Entry' });
await expect(secondItem).toBeFocused();
});Components evaluated in Playwright CT are bundled by Vite for execution within a real browser page. Attempting to import server-only modules (fs, path, crypto, child_process) or Next.js Server Actions without mocks causes instant Vite bundling failures.
// ❌ WRONG: Attempting direct filesystem access inside client browser bundle
import fs from 'fs';
export function BadComponent() {
const data = fs.readFileSync('config.json'); // Vite browser bundling fails
}
// ✅ CORRECT: Pass static configuration through serializable React props
export function GoodComponent({ config }: { config: AppConfig }) {
return <div>{config.title}</div>;
}If playwright/index.tsx omits root CSS imports (globals.css), components mount into an unstyled HTML document lacking Tailwind CSS classes. Layout assertions (toHaveCSS), responsive visibility tests, and visual regression tests will fail.
Launching browser contexts carries an initialization cost (20–50 ms per test). Executing hundreds of pure algorithmic functions, regex validators, or string manipulation utilities inside Playwright CT balloons CI duration unnecessarily. Solution: Use fast in-memory Vitest suites for pure functions and business logic; reserve Playwright CT for visual components, responsive layouts, and browser-specific user interaction flows.
Running CT exclusively in Chromium forfeits Playwright's primary advantage over jsdom: cross-engine guarantees. WebKit (Safari's engine) has unique subpixel rendering, Flexbox handling, and line-clamp characteristics that must be caught during component testing.
Install @playwright/experimental-ct-react and scaffold the playwright/index.html host template.
Import Tailwind v4 global stylesheets and setup theme providers inside playwright/index.tsx.
Import test/expect from ct-react, mount component via mount(), and assert interactive states.
Vite compiles isolated component chunks and serves them to open browser context tabs.
Execute suites concurrently across Chromium, Firefox, and WebKit engines with trace artifacts.