Playwright Component Testing: E2E for Individual React Components
AI generated
</>
{ }
Playwright · Component Testing · React · E2E
Playwright Component Testing:
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.

17 min read Playwright CT · mount() · Accessibility · CI/CD Playwright 1.44+ · React 18+

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.

11. FAQ: Playwright Component Testing

1CT vs. E2E tests: what is the difference?
CT renders components in isolation without a running app. E2E tests full user flows through the entire application. CT is faster and more targeted, E2E is for integration and backend.
2Do I need a Next.js server for CT?
No. Playwright CT bundles the component directly via Vite without a web server. Significantly faster than E2E, no backend dependencies.
3Combine Playwright CT with React Testing Library?
Possible in the same project. Vitest+RTL for fast unit tests, Playwright CT for browser dependent tests. RTL locator concepts such as getByRole are natively available in Playwright.
4How does CSS work in CT tests?
Vite/Webpack build of the application. Global CSS, CSS modules, Tailwind loaded automatically. CSS is actually rendered, real browser, no JSDOM fake.
5Is Playwright CT production ready?
Stable since Playwright 1.44. "Experimental" in the package name is historical. Actively developed further by Microsoft, used in production in large projects.
6How fast are CT tests compared to E2E?
Significantly faster. No server start, no routing. CT 1-3 seconds, E2E 5-30 seconds. Scales very well in CI with parallelization.
7Set up context providers in CT tests?
Via a beforeMount hook in playwright/index.tsx. Register QueryClientProvider, ThemeProvider, AuthProvider globally. Individual tests can pass their own wrappers at mount().
8Mock network requests in CT?
With page.route() just like in normal Playwright tests. Intercept, mock data, delay, simulate errors. page.waitForRequest() for request verification.
9Which frameworks are supported?
React, Vue, Svelte officially. Solid, Preact community adapters. React package: @playwright/experimental-ct-react.
10Test React hooks with Playwright CT?
Indirectly via a wrapper component. Use the hook, render its state, test behavior on inputs and state changes. No direct hook API, but full behavior testing is possible.