Playwright's @playwright/experimental-ct-react package mounts individual React components in a real browser using a mount() fixture: you can test a dropdown's Escape-key behavior or a form's error state without running the full app stack. It uses the same locators and assertions as your regular Playwright tests, configured through a separate playwright-ct.config.ts. This guide covers setup, testing props and event handlers, re-rendering with updated props mid-test, visual regression at the component level, and when component tests replace E2E versus when they don't.
When Component Testing Makes Sense
Use component testing for:- UI components with complex interaction logic (dropdowns, modals, date pickers)
- Components that are hard to reach via E2E (edge states, error states)
- Design system components that need visual consistency verification
- Testing event handling and props without the full app
Component tests don't cover routing, authentication, API integration, or the interaction between components. You need both.
Setup
npm init playwright@latest -- --ct
# Or add to existing project:
npm install --save-dev @playwright/experimental-ct-reactThis creates a playwright-ct.config.ts separate from your regular Playwright config:
// playwright-ct.config.ts
import { defineConfig, devices } from '@playwright/experimental-ct-react';
export default defineConfig({
testDir: './src',
testMatch: '**/*.ct.spec.{ts,tsx}',
use: {
ctPort: 3100, // Port for the component test server
viewport: { width: 1280, height: 720 },
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
});Your First Component Test
// components/Button.tsx
interface ButtonProps {
label: string;
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
onClick?: () => void;
}
export function Button({ label, variant = 'primary', disabled, onClick }: ButtonProps) {
return (
<button
className={`btn btn-${variant}`}
disabled={disabled}
onClick={onClick}
data-testid="button"
>
{label}
</button>
);
}// components/Button.ct.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './Button';
test('renders with correct label', async ({ mount }) => {
const component = await mount(<Button label="Click me" />);
await expect(component).toContainText('Click me');
});
test('applies variant class', async ({ mount }) => {
const component = await mount(<Button label="Danger" variant="danger" />);
await expect(component).toHaveClass(/btn-danger/);
});
test('disabled button cannot be clicked', async ({ mount }) => {
const component = await mount(<Button label="Disabled" disabled />);
await expect(component.getByTestId('button')).toBeDisabled();
});
test('calls onClick when clicked', async ({ mount }) => {
let clicked = false;
const component = await mount(
<Button
label="Click me"
onClick={() => { clicked = true; }}
/>
);
await component.click();
expect(clicked).toBe(true);
});Run component tests:
npx playwright test --config playwright-ct.config.tsTesting a Form Component
// components/LoginForm.tsx
interface LoginFormProps {
onSubmit: (email: string, password: string) => void;
error?: string;
}
export function LoginForm({ onSubmit, error }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
return (
<form onSubmit={(e) => {
e.preventDefault();
onSubmit(email, password);
}}>
<input
data-testid="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="Email"
/>
<input
data-testid="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Password"
/>
{error && <p data-testid="error">{error}</p>}
<button type="submit" data-testid="submit">Sign In</button>
</form>
);
}// components/LoginForm.ct.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { LoginForm } from './LoginForm';
test('submits with correct credentials', async ({ mount }) => {
let submittedEmail = '';
let submittedPassword = '';
const component = await mount(
<LoginForm
onSubmit={(email, password) => {
submittedEmail = email;
submittedPassword = password;
}}
/>
);
await component.getByTestId('email').fill('user@test.com');
await component.getByTestId('password').fill('ValidPass1');
await component.getByTestId('submit').click();
expect(submittedEmail).toBe('user@test.com');
expect(submittedPassword).toBe('ValidPass1');
});
test('shows error message when provided', async ({ mount }) => {
const component = await mount(
<LoginForm
onSubmit={() => {}}
error="Invalid credentials"
/>
);
await expect(component.getByTestId('error')).toHaveText('Invalid credentials');
});
test('does not show error when error is undefined', async ({ mount }) => {
const component = await mount(
<LoginForm onSubmit={() => {}} />
);
await expect(component.getByTestId('error')).not.toBeVisible();
});Testing a Dropdown Component
Complex components with state are where component tests shine:
// components/Dropdown.ct.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Dropdown } from './Dropdown';
const options = [
{ value: 'chrome', label: 'Chrome' },
{ value: 'firefox', label: 'Firefox' },
{ value: 'safari', label: 'Safari' },
];
test('dropdown shows options when clicked', async ({ mount }) => {
const component = await mount(
<Dropdown options={options} placeholder="Select browser" />
);
// Initially closed
await expect(component.getByTestId('dropdown-list')).not.toBeVisible();
// Click to open
await component.getByTestId('dropdown-trigger').click();
// Now visible with all options
await expect(component.getByTestId('dropdown-list')).toBeVisible();
const items = component.getByTestId('dropdown-option');
await expect(items).toHaveCount(3);
await expect(items.nth(0)).toHaveText('Chrome');
});
test('closes after selecting an option', async ({ mount }) => {
let selected = '';
const component = await mount(
<Dropdown
options={options}
placeholder="Select browser"
onSelect={(value) => { selected = value; }}
/>
);
await component.getByTestId('dropdown-trigger').click();
await component.getByText('Firefox').click();
expect(selected).toBe('firefox');
await expect(component.getByTestId('dropdown-list')).not.toBeVisible();
await expect(component.getByTestId('dropdown-trigger')).toHaveText('Firefox');
});
test('closes on Escape key', async ({ mount }) => {
const component = await mount(
<Dropdown options={options} placeholder="Select" />
);
await component.getByTestId('dropdown-trigger').click();
await expect(component.getByTestId('dropdown-list')).toBeVisible();
await component.press('Escape');
await expect(component.getByTestId('dropdown-list')).not.toBeVisible();
});Updating Components Mid-Test
You can re-render a component with different props:
test('error message updates when props change', async ({ mount }) => {
const component = await mount(
<LoginForm onSubmit={() => {}} />
);
// Initially no error
await expect(component.getByTestId('error')).not.toBeVisible();
// Update props to add error
await component.update(
<LoginForm onSubmit={() => {}} error="Session expired" />
);
await expect(component.getByTestId('error')).toHaveText('Session expired');
// Clear error
await component.update(
<LoginForm onSubmit={() => {}} />
);
await expect(component.getByTestId('error')).not.toBeVisible();
});Hooks in Component Tests
Playwright component tests support the same hooks as regular Playwright tests:
import { test, expect, beforeEach } from '@playwright/experimental-ct-react';
let clickCount = 0;
test.beforeEach(() => {
clickCount = 0;
});
test('counts clicks correctly', async ({ mount }) => {
const component = await mount(
<Button label="Click" onClick={() => { clickCount++; }} />
);
await component.click();
await component.click();
await component.click();
expect(clickCount).toBe(3);
});Visual Testing with Components
Component tests work well with visual regression:
test('button variants look correct', async ({ mount }) => {
for (const variant of ['primary', 'secondary', 'danger'] as const) {
const component = await mount(<Button label={variant} variant={variant} />);
await expect(component).toHaveScreenshot(`button-${variant}.png`);
}
});This is more reliable than full-page screenshots — fewer moving parts, easier to isolate what changed.
Vue and Svelte
The same concepts apply to Vue and Svelte:
# Vue
npm install --save-dev @playwright/experimental-ct-vue
# Svelte
npm install --save-dev @playwright/experimental-ct-svelte<!-- Vue test -->
<script setup>
import { test, expect } from '@playwright/experimental-ct-vue'
import MyComponent from './MyComponent.vue'
</script>Component vs E2E: When to Use Which
| Scenario | Component Test | E2E Test |
|----------|---------------|---------|
| Button click handler | ✅ | ❌ |
| Dropdown opens/closes | ✅ | ❌ |
| Form validation messages | ✅ | ❌ |
| Login flow end-to-end | ❌ | ✅ |
| Navigation between pages | ❌ | ✅ |
| API integration | ❌ | ✅ |
| Component visual regression | ✅ | Sometimes |
Summary
# Setup
npm install --save-dev @playwright/experimental-ct-react
# Config: playwright-ct.config.ts
# Tests: ComponentName.ct.spec.tsx
# Run
npx playwright test --config playwright-ct.config.tsKey operations:
mount(— render a component in a real browser) component.getByTestId(...)— standard Playwright locatorscomponent.click()— standard Playwright actionscomponent.update(— re-render with new props) expect(component).toHaveScreenshot()— visual regression
Component testing fills the gap between unit tests (too isolated) and E2E tests (too slow). For UI-heavy applications with complex components, it's worth adding to your test strategy.
→ See also: The Test Pyramid Explained for QA Engineers | Playwright Assertions: The Complete Guide | Visual Regression Testing with Playwright: toHaveScreenshot Without Applitools