Component testing with Playwright represents a shift in how we validate UI behavior without spinning up a full application. Instead of testing through the entire stack, we mount components directly in a real browser and verify their rendering and interactions. This Day 30 guide walks you through setting up and writing component tests that catch bugs at the component level.
Experimental component testing for React, Vue and Svelte
Playwright's component testing feature lets you mount and test individual components in isolation using a real browser context. This is different from unit tests that might use shallow rendering or snapshots—here, we get genuine DOM rendering, event handling, and browser APIs firing exactly as they would in production. The feature remains experimental, but teams in production have found it invaluable for catching integration issues early.
Component testing bridges the gap between unit and end-to-end testing. A component might pass unit tests with mocked dependencies, but fail when its actual DOM interacts with real CSS, animations, or browser timing. By rendering in a real browser, we catch these gaps.
Playwright supports React, Vue, and Svelte out of the box. Each framework has a corresponding adapter that handles mounting and lifecycle management. To get started, install the component testing dependencies:
npm install --save-dev @playwright/experimental-ct-react @playwright/testThen configure playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './src/components',
testMatch: '**/*.spec.ts',
use: {
ctPort: 3100,
},
webServer: {
command: 'npm run dev',
port: 3100,
reuseExistingServer: !process.env.CI,
},
});
The key difference from standard Playwright tests is the ctPort (component testing port) and the absence of a full baseURL. Playwright will spawn a lightweight dev server just for your components.
Mount components with props
Mounting a component is straightforward—you use the mount function provided by Playwright's test runner. This function accepts your component and its props, then renders it in the test browser.
Consider a real-world scenario: testing a checkout form component that accepts products, a callback for order submission, and a disabled state. Here's what a test looks like:
import { test, expect } from '@playwright/experimental-ct-react';
import { CheckoutForm } from './CheckoutForm';
test('should render checkout form with products', async ({ mount }) => {
const products = [
{ id: 1, name: 'Widget', price: 29.99 },
{ id: 2, name: 'Gadget', price: 49.99 },
];
const component = await mount(
{}}
disabled={false}
/>
);
await expect(component.locator('text=Widget')).toBeVisible();
await expect(component.locator('text=Gadget')).toBeVisible();
});
The mount function returns a locator-like object you can query just like in regular Playwright tests. You pass JSX directly; no need to render to a string or register a global component library. This keeps tests focused and reduces boilerplate.
Props can be dynamic. In one test, you might mount with empty state; in another, with a large dataset:
test('should handle empty product list', async ({ mount }) => {
const component = await mount(
{}}
disabled={false}
/>
);
await expect(component.locator('text=No products available')).toBeVisible();
});
Callbacks passed as props work too. You can track whether they were called and with what arguments:
test('should call onSubmit with form data', async ({ mount }) => {
let submittedData;
const component = await mount(
{
submittedData = data;
}}
disabled={false}
/>
);
await component.locator('button:has-text("Submit Order")').click();
expect(submittedData).toEqual({
products: [{ id: 1, name: 'Widget', price: 29.99 }],
total: 29.99,
});
});
Real browser rendering
The magic of Playwright's component testing is that components render in a real browser, not a headless JSDOM or shallow renderer. This means CSS, animations, and timing behave exactly as they will in production.
Say your CheckoutForm has a button that shows a loading spinner while the order submits. A unit test might mock the request, but in a real browser, CSS animations trigger and state changes propagate through React's reconciliation engine. Component tests catch these nuances:
test('should show loading state during submission', async ({ mount }) => {
let resolveSubmit;
const submitPromise = new Promise((resolve) => {
resolveSubmit = resolve;
});
const component = await mount(
{
await submitPromise;
}}
disabled={false}
/>
);
await component.locator('button:has-text("Submit Order")').click();
// In a real browser, the loading spinner appears immediately
await expect(component.locator('[data-testid="loading-spinner"]')).toBeVisible();
// Spinner is still there while the promise hasn't resolved
await expect(component.locator('button:has-text("Submit Order")')).toBeDisabled();
// Resolve the submission
resolveSubmit();
// Spinner disappears
await expect(component.locator('[data-testid="loading-spinner"]')).not.toBeVisible();
await expect(component.locator('button:has-text("Submit Order")')).toBeEnabled();
});
Real browser rendering also means hover states, focus management, and keyboard events work naturally. No mocking required:
test('should apply hover styles to product card', async ({ mount }) => {
const component = await mount(
{}}
disabled={false}
/>
);
const productCard = component.locator('[data-testid="product-card"]').first();
// Hover the card
await productCard.hover();
// Verify computed style (this works in real browsers)
const bgColor = await productCard.evaluate((el) =>
window.getComputedStyle(el).backgroundColor
);
expect(bgColor).toBe('rgb(240, 240, 240)'); // example hover color
});
Use for complex interactive components
Component tests shine when your component has complex interactions, conditional rendering, or multiple state changes. Modal dialogs, dropdowns, multi-step forms, and data tables all benefit from component testing because you can simulate real user flows.
Imagine a product selector component that filters, sorts, and allows multi-select. Building a component test forces you to think through every interaction path:
test('should filter and multi-select products', async ({ mount }) => {
const allProducts = [
{ id: 1, name: 'Widget A', category: 'widgets', price: 29.99 },
{ id: 2, name: 'Widget B', category: 'widgets', price: 39.99 },
{ id: 3, name: 'Gadget X', category: 'gadgets', price: 49.99 },
];
const selected = [];
const component = await mount(
selected.push(product)}
/>
);
// Filter by category
await component.locator('select[name="category"]').selectOption('widgets');
// Should now show only 2 widgets
const rows = component.locator('tbody tr');
await expect(rows).toHaveCount(2);
// Select both widgets
await component.locator('input[type="checkbox"]').first().check();
await component.locator('input[type="checkbox"]').nth(1).check();
// Verify selections
expect(selected).toHaveLength(2);
expect(selected[0].id).toBe(1);
expect(selected[1].id).toBe(2);
});
Component tests also excel at verifying accessibility. You can navigate with keyboard, check focus order, and validate ARIA attributes—all in a real browser:
test('should be keyboard accessible', async ({ mount }) => {
const component = await mount(
{}}
disabled={false}
/>
);
// Tab to the submit button
await component.locator('body').press('Tab');
await component.locator('body').press('Tab');
// Verify it's focused
await expect(component.locator('button:has-text("Submit Order")')).toBeFocused();
// Press Enter to submit
await component.locator('button:has-text("Submit Order")').press('Enter');
// Verify form was submitted
await expect(component.locator('text=Order received')).toBeVisible();
});
Common mistakes
- Forgetting to await mount: Always
awaitthe mount call. Component tests are async by nature, and not awaiting leads to timing issues. - Testing component internals instead of behavior: Don't test implementation details like local state or internal function calls. Test what the user sees and does.
- Not cleaning up side effects in callbacks: If a callback mutates external state (like a global store), reset it between tests to avoid flaky tests.
- Ignoring timeouts for async operations: When your component awaits an API call or timer, increase the test timeout or explicitly wait with
waitFor. - Over-testing with component tests: Not every component needs a component test. Simple presentational components that don't change behavior based on interactions are fine with unit tests or visual regression.
Key takeaways
- Component testing mounts individual components in a real browser, catching rendering and interaction bugs that unit tests miss.
- Use the
mountfunction to render components with props and callbacks, exactly as you'd use them in your app. - Real browser rendering means CSS, animations, and timing work naturally—no mocks or shallow rendering required.
- Component tests are ideal for complex interactive components like forms, modals, and multi-step wizards.
- Focus on behavior, not implementation. Test what users see and do, not internal state or function calls.
- Keep component tests lightweight by testing a single component in isolation; use end-to-end tests for cross-component flows.






Comments
Loading comments…