Component Testing with Playwright
component-testing
Isolated component testing for React, Vue, and Svelte with Playwright. Use when testing UI components in isolation, testing component interactions, or building component test suites.
SKILL.md
Full skill instructions
Component Testing with Playwright
Test UI components in isolation using Playwright's experimental component testing feature. Supports React, Vue, Svelte, and Solid.
Quick Start
// Button.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './Button';
test('button click triggers callback', async ({ mount }) => {
let clicked = false;
const component = await mount(
<Button onClick={() => clicked = true}>Click me</Button>
);
await component.click();
expect(clicked).toBe(true);
});
Installation
React
npm init playwright@latest -- --ct
# Select React when prompted
Or manually:
npm install -D @playwright/experimental-ct-react
Vue
npm install -D @playwright/experimental-ct-vue
Svelte
npm install -D @playwright/experimental-ct-svelte
Configuration
playwright-ct.config.ts:
import { defineConfig, devices } from '@playwright/experimental-ct-react';
export default defineConfig({
testDir: './src',
testMatch: '**/*.spec.tsx',
use: {
ctPort: 3100,
ctViteConfig: {
// Custom Vite config for component tests
},
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
});
React Component Testing
Basic Mount
import { test, expect } from '@playwright/experimental-ct-react';
import { UserCard } from './UserCard';
test('displays user info', async ({ mount }) => {
const component = await mount(
<UserCard
name="John Doe"
email="[email protected]"
/>
);
await expect(component.getByText('John Doe')).toBeVisible();
await expect(component.getByText('[email protected]')).toBeVisible();
});
With Props
test('button variants', async ({ mount }) => {
// Primary variant
const primary = await mount(<Button variant="primary">Save</Button>);
await expect(primary).toHaveClass(/btn-primary/);
// Secondary variant
const secondary = await mount(<Button variant="secondary">Cancel</Button>);
await expect(secondary).toHaveClass(/btn-secondary/);
});
With Event Handlers
test('form submission', async ({ mount }) => {
const submittedData: any[] = [];
const component = await mount(
<ContactForm onSubmit={(data) => submittedData.push(data)} />
);
await component.getByLabel('Name').fill('John');
await component.getByLabel('Email').fill('[email protected]');
await component.getByRole('button', { name: 'Submit' }).click();
expect(submittedData).toHaveLength(1);
expect(submittedData[0]).toEqual({
name: 'John',
email: '[email protected]',
});
});
With Context Providers
// Create wrapper for providers
import { ThemeProvider } from './ThemeContext';
test('themed component', async ({ mount }) => {
const component = await mount(
<ThemeProvider theme="dark">
<ThemedButton>Click</ThemedButton>
</ThemeProvider>
);
await expect(component).toHaveClass(/dark-theme/);
});
With Slots/Children
test('card with custom content', async ({ mount }) => {
const component = await mount(
<Card>
<CardHeader>Title</CardHeader>
<CardBody>Content here</CardBody>
<CardFooter>
<Button>Action</Button>
</CardFooter>
</Card>
);
await expect(component.getByText('Title')).toBeVisible();
await expect(component.getByText('Content here')).toBeVisible();
await expect(component.getByRole('button')).toBeVisible();
});
Vue Component Testing
// Counter.spec.ts
import { test, expect } from '@playwright/experimental-ct-vue';
import Counter from './Counter.vue';
test('counter increments', async ({ mount }) => {
const component = await mount(Counter, {
props: {
initialCount: 0,
},
});
await expect(component.getByText('Count: 0')).toBeVisible();
await component.getByRole('button', { name: '+' }).click();
await expect(component.getByText('Count: 1')).toBeVisible();
});
With Slots
test('card with slots', async ({ mount }) => {
const component = await mount(Card, {
slots: {
default: '<p>Card content</p>',
header: '<h2>Card Title</h2>',
},
});
await expect(component.getByText('Card Title')).toBeVisible();
await expect(component.getByText('Card content')).toBeVisible();
});
With Vuex/Pinia
import { test, expect } from '@playwright/experimental-ct-vue';
import { createTestingPinia } from '@pinia/testing';
import UserProfile from './UserProfile.vue';
test('displays user from store', async ({ mount }) => {
const component = await mount(UserProfile, {
global: {
plugins: [
createTestingPinia({
initialState: {
user: { name: 'John', email: '[email protected]' },
},
}),
],
},
});
await expect(component.getByText('John')).toBeVisible();
});
Svelte Component Testing
// Button.spec.ts
import { test, expect } from '@playwright/experimental-ct-svelte';
import Button from './Button.svelte';
test('button emits click', async ({ mount }) => {
let clicked = false;
const component = await mount(Button, {
props: {
label: 'Click me',
},
on: {
click: () => clicked = true,
},
});
await component.click();
expect(clicked).toBe(true);
});
Testing Patterns
Visual Regression
test('button visual states', async ({ mount }) => {
const component = await mount(<Button>Click</Button>);
// Default state
await expect(component).toHaveScreenshot('button-default.png');
// Hover state
await component.hover();
await expect(component).toHaveScreenshot('button-hover.png');
// Focus state
await component.focus();
await expect(component).toHaveScreenshot('button-focus.png');
});
Accessibility
import AxeBuilder from '@axe-core/playwright';
test('button is accessible', async ({ mount, page }) => {
await mount(<Button>Submit</Button>);
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
Responsive Behavior
test('responsive navigation', async ({ mount, page }) => {
const component = await mount(<Navigation />);
// Desktop - horizontal nav
await page.setViewportSize({ width: 1280, height: 720 });
await expect(component.locator('.nav-horizontal')).toBeVisible();
// Mobile - hamburger menu
await page.setViewportSize({ width: 375, height: 667 });
await expect(component.locator('.hamburger-menu')).toBeVisible();
});
Loading States
test('async component states', async ({ mount }) => {
const component = await mount(<DataTable dataUrl="/api/data" />);
// Loading state
await expect(component.getByText('Loading...')).toBeVisible();
// Wait for data
await expect(component.getByRole('table')).toBeVisible();
await expect(component.getByText('Loading...')).not.toBeVisible();
});
Error States
test('error handling', async ({ mount, page }) => {
// Mock failed API
await page.route('**/api/data', route => {
route.fulfill({ status: 500 });
});
const component = await mount(<DataTable dataUrl="/api/data" />);
await expect(component.getByText(/error/i)).toBeVisible();
await expect(component.getByRole('button', { name: 'Retry' })).toBeVisible();
});
Hooks and Fixtures
Before Each Test
import { test as base, expect } from '@playwright/experimental-ct-react';
const test = base.extend({
autoMockApi: async ({ page }, use) => {
await page.route('**/api/**', route => {
route.fulfill({ status: 200, body: '{}' });
});
await use();
},
});
test('component with mocked api', async ({ mount, autoMockApi }) => {
const component = await mount(<ApiComponent />);
// API calls are automatically mocked
});
Custom Mount
const test = base.extend({
mountWithProviders: async ({ mount }, use) => {
const wrappedMount = async (component: JSX.Element) => {
return mount(
<ThemeProvider>
<AuthProvider>
{component}
</AuthProvider>
</ThemeProvider>
);
};
await use(wrappedMount);
},
});
test('with providers', async ({ mountWithProviders }) => {
const component = await mountWithProviders(<Dashboard />);
// Component has access to theme and auth contexts
});
Running Tests
# Run all component tests
npx playwright test -c playwright-ct.config.ts
# Run specific test file
npx playwright test Button.spec.tsx -c playwright-ct.config.ts
# Run with UI mode
npx playwright test -c playwright-ct.config.ts --ui
# Update snapshots
npx playwright test -c playwright-ct.config.ts --update-snapshots
Best Practices
- Test behavior, not implementation - Focus on user interactions
- Keep components isolated - Mock external dependencies
- Test all states - Default, loading, error, empty, success
- Use semantic queries -
getByRole,getByLabelover CSS selectors - Combine with E2E - Component tests for logic, E2E for integration
References
references/react-patterns.md- React-specific testing patternsreferences/vue-patterns.md- Vue-specific testing patterns
