E2E for Individual React Components
JSDOM based tests sometimes lie: they simulate browser behavior but do not test what a real browser actually does with a component. Playwright Component Testing renders every React component in a real Chromium, Firefox or WebKit instance, without spinning up the whole application.
Table of Contents
- 1. Why Playwright Component Testing? The argument against JSDOM
- 2. Setup: integrating Playwright CT into a React project
- 3. mount(): rendering components in real browsers
- 4. Testing user events and interactions
- 5. Testing forms and validation in the browser
- 6. Checking accessibility with axe-core and Playwright
- 7. Network mocking in component tests
- 8. Visual regression and screenshot comparisons
- 9. Playwright CT vs. Vitest/RTL vs. Cypress CT
- 10. Summary
- 11. FAQ
1. Why Playwright Component Testing? The argument against JSDOM
Playwright Component Testing solves a fundamental problem of classic React tests: JSDOM, the simulated DOM used by Testing Library and Jest, is not a real browser. It implements web APIs incompletely, does not process CSS, performs no layout calculations, and does not simulate real browser event bubbling cascades. Tests that run in JSDOM environments can pass even though the component is broken in a real browser, because the simulation does not reflect reality.
Playwright Component Testing takes a different approach: it starts real browser instances (Chromium, Firefox, WebKit), renders individual React components there in isolation, and allows all the usual Playwright assertions and interactions directly on the component. The crucial difference from normal Playwright E2E tests: you do not need a running application, a web server, or a backend. The component is mounted directly into the browser, similar to Storybook, but with full testing support.
That makes Playwright Component Testing the ideal tool for cases where JSDOM tests fail: CSS dependent interactions (hover states, visibility), browser specific form behavior (autocomplete, native validation), real focus management for accessibility tests, and scroll behavior. At the same time, Playwright CT is faster than full E2E tests because no application routing layer needs to be traversed.
2. Setup: integrating Playwright CT into a React project
Integrating Playwright Component Testing into an existing React project is surprisingly simple. npm init playwright@latest -- --ct sets up the project and creates the necessary configuration file playwright-ct.config.ts. For React projects using Vite or Webpack, the corresponding adapters are configured automatically. The setup also creates a playwright/index.html file, where global styles, providers and contexts can be defined, everything every component needs at mount time.
Component tests are written in files with the suffix .ct.tsx or .spec.tsx and import mount from @playwright/experimental-ct-react. Important: Playwright CT uses Vite or Webpack under the hood to bundle the components, which means all imports and aliases from the existing configuration work automatically. CSS modules, SCSS, Tailwind and other preprocessors are taken over from the existing build configuration.
// playwright-ct.config.ts: Component Testing configuration
import { defineConfig, devices } from '@playwright/experimental-ct-react'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
testDir: './src',
testMatch: '**/*.ct.tsx',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [['html', { outputFolder: 'playwright-ct-report' }]],
use: {
ctViteConfig: {
plugins: [react()],
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
},
viewport: { width: 1280, height: 720 },
actionTimeout: 5000,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
})
// playwright/index.tsx: Global providers for all component tests
import React from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import '../src/index.css' // import global CSS including Tailwind
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
})
// beforeMount hook: wrap every mounted component with providers
export const beforeMount = ({ App }) => {
return (
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
)
}
3. mount(): rendering components in real browsers
The mount() function is the centerpiece of Playwright Component Testing. It renders a React component in a real browser and returns a Playwright Locator object, through which you can interact with the component and run assertions. The call is analogous to React Testing Library's render(), but with the crucial difference: you get back a full Playwright locator that supports all Playwright assertions, interactions and screenshots.
What is special about Playwright CT is that you have full control over props, context and state. With component.update() you can change props dynamically and check how the component reacts to prop changes. With hooks that are injected as props, you can also test the behavior on state changes. Between tests, the component is completely remounted into a fresh DOM container, there is no state pollution between tests.
4. Testing user events and interactions
The biggest advantage of Playwright Component Testing over JSDOM based tests lies in the simulation of user events. Playwright simulates real browser events with realistic timing, focus management and event bubbling, exactly like a real user. page.click(), page.fill(), page.keyboard.press() and page.hover() behave as they would in a real browser, not in a simulated environment. That surfaces bugs that would never become visible in JSDOM.
A concrete example: a dropdown that should close on an outside click almost always works correctly in JSDOM tests, because JSDOM handles click-outside events in a simplified way. In a real browser with Playwright CT you can check precisely: clicking the trigger opens the dropdown, clicking a dropdown item closes it, clicking outside closes it, the Escape key closes it. The expectations are precise and test real browser behavior.
// SearchBox.ct.tsx: Testing user interactions in a real browser
import { test, expect } from '@playwright/experimental-ct-react'
import { SearchBox } from './SearchBox'
// Mock data for testing without real API calls
const mockResults = [
{ id: '1', title: 'Next.js App Router', url: '/blog/next-js-app-router' },
{ id: '2', title: 'Playwright Component Testing', url: '/blog/playwright-ct' },
]
test('shows results when user types in search box', async ({ mount, page }) => {
// Mount component with mock search function
const component = await mount(
<SearchBox
onSearch={async (query) => mockResults.filter(r => r.title.includes(query))}
placeholder="Search articles..."
/>
)
// Verify initial state: input visible, no results shown
await expect(component.getByRole('searchbox')).toBeVisible()
await expect(component.getByRole('listbox')).not.toBeVisible()
// Simulate typing, Playwright fires real keyboard events
await component.getByRole('searchbox').fill('Playwright')
await page.waitForTimeout(300) // debounce delay
// Results should appear after typing
await expect(component.getByRole('listbox')).toBeVisible()
await expect(component.getByRole('option')).toHaveCount(1)
await expect(component.getByRole('option')).toContainText('Playwright Component Testing')
// Pressing Escape should close the results
await page.keyboard.press('Escape')
await expect(component.getByRole('listbox')).not.toBeVisible()
})
test('keyboard navigation works correctly', async ({ mount, page }) => {
const component = await mount(<SearchBox onSearch={async () => mockResults} />)
await component.getByRole('searchbox').fill('test')
await page.keyboard.press('ArrowDown') // focus first result
await page.keyboard.press('ArrowDown') // focus second result
await page.keyboard.press('Enter') // select focused result
// Verify that the correct result was selected
await expect(component.getByTestId('selected-result')).toContainText('Playwright Component Testing')
})
5. Testing forms and validation in the browser
Form tests are an area where Playwright Component Testing is particularly superior. Native HTML5 validation (required, pattern, min/max) does not work reliably in JSDOM, the browser does not enforce a native validation bubble. In a real browser with Playwright CT you can test precisely whether submitting an empty field triggers native validation, whether custom validation messages are displayed correctly, and whether react-hook-form or Zod validation messages appear on the right event.
Particularly valuable: Playwright CT can test whether a form submit actually triggers a network request, whether optimistic UI updates are displayed correctly, and whether error states are correctly reset after a failed submit. These are exactly the scenarios that are often not covered by pure unit-test based setups and only show up in E2E tests, with Playwright Component Testing you can test them directly on the component.
6. Checking accessibility with axe-core and Playwright
Playwright Component Testing is the ideal tool for accessibility tests because accessibility happens in a real browser. ARIA roles, focus management and screen reader compatibility depend on the real DOM and real browser behavior. With the @axe-core/playwright integration you can automatically run an axe accessibility scan after mounting a component, which reports known WCAG violations.
Beyond automatic axe scans, you can use Playwright CT to test manual accessibility flows: tab navigation through interactive elements, activating buttons via Enter or Space, correct focus return after closing a modal, announcement of live region updates. These tests require real browser behavior and cannot be reliably carried out in JSDOM. A component test with Playwright CT that combines tab navigation and an axe scan gives more confidence than ten unit tests combined.
// Modal.ct.tsx: Accessibility testing in real browsers
import { test, expect } from '@playwright/experimental-ct-react'
import AxeBuilder from '@axe-core/playwright'
import { Modal } from './Modal'
test('modal has no accessibility violations', async ({ mount, page }) => {
await mount(
<Modal isOpen title="Confirm Delete" onClose={() => {}}>
<p>Are you sure you want to delete this item?</p>
<button>Cancel</button>
<button>Confirm</button>
</Modal>
)
// Run axe accessibility scan on the mounted component
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze()
expect(results.violations).toEqual([]) // fails if any WCAG violation found
})
test('focus is trapped inside modal when open', async ({ mount, page }) => {
await mount(<Modal isOpen title="Dialog" onClose={() => {}}>
<button>First</button>
<button>Second</button>
<button>Third</button>
</Modal>)
// When modal opens, focus should move to the dialog
await expect(page.getByRole('dialog')).toBeFocused()
// Tab through all focusable elements, should stay inside modal
await page.keyboard.press('Tab')
await expect(page.getByRole('button', { name: 'First' })).toBeFocused()
await page.keyboard.press('Tab')
await expect(page.getByRole('button', { name: 'Second' })).toBeFocused()
await page.keyboard.press('Tab')
await expect(page.getByRole('button', { name: 'Third' })).toBeFocused()
await page.keyboard.press('Tab') // wrap around, should NOT leave modal
await expect(page.getByRole('button', { name: 'First' })).toBeFocused()
})
7. Network mocking in component tests
Playwright Component Testing offers full network mocking through the same page.route() API as normal Playwright tests. This makes it possible to intercept HTTP requests triggered by a component and answer them with mock data, without the component ever needing to know that it is being mocked. Particularly valuable for components that make direct API calls (for example with fetch, axios or React Query): you can test loading states, error states and success states with full control over the API response.
The mocking model of Playwright CT is more powerful than that of MSW (Mock Service Worker): you can simulate network delays (await page.waitForTimeout(500) inside the route handler function), test different HTTP status codes, and check whether the component correctly reacts to a 429 Too Many Requests or 500 Internal Server Error. You can also use page.waitForRequest() to verify that the component calls the correct endpoint with the correct parameters.
8. Visual regression and screenshot comparisons
Because Playwright Component Testing runs in real browsers, screenshot comparisons (visual regression testing) are directly available. With expect(component).toHaveScreenshot('component-name.png'), a baseline screenshot is created on the first run and compared on every subsequent run. Pixel level differences are detected and visualized in an HTML report. That is particularly valuable for design system components: a CSS change that unintentionally alters the layout of a button is caught immediately by the screenshot comparison.
For mobile testing you can set the viewport of the browser instance in Playwright CT to typical mobile sizes, and thereby test how components look at different screen widths. Responsive behavior that depends on CSS media queries can be checked directly in component tests this way, no need to switch to E2E tests. That makes Playwright Component Testing the most complete tool for UI testing currently available for React.
9. Playwright CT vs. Vitest/RTL vs. Cypress CT
The Playwright Component Testing approach differs significantly from other testing strategies. Vitest with React Testing Library is faster, needs no browser, and is ideal for unit tests of logic and simple rendering tests. Cypress Component Testing is also browser based, but has a slower startup time and less extensive browser support than Playwright. The choice between the approaches depends on which class of bugs you want to find.
| Criterion | Playwright CT | Vitest + RTL | Cypress CT |
|---|---|---|---|
| Real browser | Yes (Chromium/FF/WebKit) | No (JSDOM) | Yes (Chrome/FF) |
| Test speed | Medium | Very fast | Slow |
| Screenshot tests | Natively supported | Not possible | Plugin required |
| Accessibility | axe + real focus tests | axe, limited | axe, browser based |
| CI speed | Good (parallel) | Very good | Slow |
The recommendation: Playwright Component Testing complements, but does not replace, Vitest+RTL. Unit tests for logic and simple rendering stay with Vitest, they are faster and easier to write. Playwright CT comes in for all tests that require real browser behavior: accessibility, CSS dependent interactions, native form validation and screenshot comparisons. The result is a testing strategy with multiple layers that covers different classes of bugs.
Mironsoft
React testing, Playwright integration and test strategy
Want to test your React components reliably?
We implement Playwright Component Testing in your React project: setup, test strategy, accessibility tests and CI integration for maximum test coverage with minimal overhead.
Test setup
Configure Playwright CT, set up providers and write the first tests for critical components
Accessibility
axe-core integration, focus tests and WCAG compliance for all interactive components
CI integration
Integrate Playwright CT into GitHub Actions or GitLab CI with parallelized execution
10. Summary
Playwright Component Testing closes the gap between unit tests (too isolated, JSDOM) and E2E tests (too slow, requires a full app). Real browsers, complete CSS rendering, real event simulation and native accessibility support make Playwright CT the ideal tool for all tests that require real browser behavior. The setup is simple, the integration into existing Playwright infrastructure is smooth, and the test speed is clearly below that of full E2E tests.
The strategic recommendation: a testing pyramid with three layers. Vitest+RTL for fast unit tests of logic, Playwright Component Testing for browser dependent component behavior and accessibility, and full Playwright E2E tests for critical user flows. This combination gives maximum test coverage at optimized execution speed. Playwright CT is not competition for other testing approaches, it is the missing layer between unit and E2E tests.
Playwright Component Testing: The Essentials at a Glance
Real browser
Chromium, Firefox, WebKit, no JSDOM. CSS rendering, real events, native form validation and focus management just like a real user.
mount() API
Direct rendering of React components without a running app. Props, context and state fully controllable. component.update() for dynamic props.
Accessibility
axe-core integration for WCAG scans. Real tab navigation and focus tests. Check ARIA roles in a real browser.
Screenshot tests
Natively supported visual regression. toHaveScreenshot() for pixel level comparisons. Responsive tests through viewport configuration.