EmpireUI
Get Pro
← Blog7 min read#playwright#react-testing#component-testing

Playwright Component Tests: Browser-Real Testing for React

Playwright component testing runs your React components in a real browser — no jsdom fakes. Here's how to set it up, write useful tests, and catch real rendering bugs.

Code editor showing browser test output with green passing tests on a dark terminal screen

Why Playwright Component Tests Exist

Honestly, jsdom has been lying to you for years. You write a test, it passes, you ship — and then a real browser renders your component completely wrong. That's the gap Playwright component testing closes: it runs your components inside an actual Chromium, Firefox, or WebKit process, not a fake DOM bolted onto Node.js.

Playwright's component testing mode (introduced experimentally in v1.22 and stabilized through v1.40+) works differently from Playwright's page-level E2E tests. Instead of navigating to a full URL, you mount a single React component in an isolated iframe, interact with it, and assert against what's actually rendered. Think of it as Storybook's visual isolation meets Cypress Component Testing, but with Playwright's network interception and trace viewer baked in.

If your team already uses Playwright for E2E tests, there's almost no new infrastructure cost. The same playwright.config.ts, the same CI runner, the same @playwright/test assertions. You're just adding a ct mount command to your workflow.

Setting Up Playwright Component Testing in a React Project

Start by installing the right packages. You'll need @playwright/test at v1.40.0 or later and @playwright/experimental-ct-react — yes, it still carries the experimental label in the package name even though it's production-ready for most teams.

npm install -D @playwright/test @playwright/experimental-ct-react
npx playwright install chromium

Then create a playwright-ct.config.ts alongside your existing playwright.config.ts. Keeping them separate matters — you don't want component tests and E2E tests sharing the same timeout settings or test directories.

// playwright-ct.config.ts
import { defineConfig } from '@playwright/experimental-ct-react';
import react from '@vitejs/plugin-react';

export default defineConfig({
  testDir: './src',
  testMatch: '**/*.ct.tsx',
  use: {
    ctPort: 3100,
    ctViteConfig: {
      plugins: [react()],
      css: {
        postcss: {
          plugins: [require('tailwindcss'), require('autoprefixer')],
        },
      },
    },
  },
});

The ctViteConfig key is where most of the setup lives. You're telling Playwright to spin up a Vite dev server — on port 3100 by default — and serve your component through it. That means Tailwind v4.0.2 classes actually resolve, your CSS custom properties actually apply, and anything your component imports (fonts, SVGs, icons) actually loads.

Writing Your First Component Test

The API is clean. You import mount from @playwright/experimental-ct-react and it returns a Playwright Locator you can interact with exactly as you would in a page test. There's no extra assertion library to learn.

// src/components/Button.ct.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './Button';

test('renders with correct label and fires onClick', async ({ mount }) => {
  let clicked = false;

  const component = await mount(
    <Button label="Save Changes" onClick={() => { clicked = true; }} />
  );

  await expect(component).toContainText('Save Changes');
  await component.click();
  expect(clicked).toBe(true);
});

test('applies ghost variant styles', async ({ mount, page }) => {
  const component = await mount(<Button label="Cancel" variant="ghost" />);

  // Real CSS computed from Tailwind — no jsdom guessing
  const bgColor = await component.evaluate(
    el => getComputedStyle(el).backgroundColor
  );
  expect(bgColor).toBe('rgba(0, 0, 0, 0)');
});

That second test is the one that would silently pass in jsdom and fail in production. getComputedStyle in a real browser returns the actual computed value — rgba(0, 0, 0, 0) for a transparent background. jsdom would return an empty string or a wrong default. If you're building components with layered styles like glassmorphism — backdrop-filter: blur(12px), background: rgba(255,255,255,0.15) — this distinction is everything. You can check out the glassmorphism generator for real examples of styles that need browser-real assertions.

Testing Tailwind-Styled Components Accurately

Here's the thing: Tailwind's JIT compiler generates CSS at build time based on class names it finds in your source. In jsdom-based tests, that CSS never gets generated. Your className="rounded-2xl shadow-lg" classes are just strings sitting on a DOM node with no visual meaning. Playwright component tests actually run Tailwind's PostCSS pipeline through Vite, so shadow-lg results in real computed box-shadow values.

This matters even more if you're using arbitrary values. A class like gap-[8px] or border-[rgba(255,255,255,0.2)] won't be in any pre-generated CSS file — Vite and Tailwind generate it on demand. Your component test runs inside that same Vite pipeline, so arbitrary values work exactly as they do in the browser. When you're validating spacing tokens or checking that a box-shadow matches a design spec, you'll want to read the box-shadow CSS guide for the right property names to assert against.

One useful pattern: pair Playwright component tests with visual snapshots. Run await page.screenshot({ path: 'snapshots/button-ghost.png' }) and commit those snapshots to your repo. On CI, any drift in computed styles triggers a diff. It's not pixel-perfect screenshot comparison (unless you want it to be), it's just a safety net for accidental style regressions.

You can also test your theme toggle in React by mounting a component inside a dark-mode wrapper and asserting computed color values change appropriately. Real browser, real CSS variables, real computed results.

Fixtures, Context Providers, and Setup Files

Most real React components aren't standalone. They need a theme context, a router context, a query client. Playwright component testing handles this through a playwright/index.tsx file that wraps every mounted component.

// playwright/index.tsx  — this file is auto-discovered
import { beforeMount } from '@playwright/experimental-ct-react/hooks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '../src/contexts/ThemeContext';

beforeMount(async ({ App }) => {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });

  return (
    <QueryClientProvider client={queryClient}>
      <ThemeProvider defaultTheme="dark">
        <App />
      </ThemeProvider>
    </QueryClientProvider>
  );
});

Every component you mount in your .ct.tsx files automatically gets wrapped in this provider tree. You don't have to repeat the boilerplate in each test file. And if a specific test needs different context — say, a light-mode test vs a dark-mode test — you can pass props into beforeMount or use Playwright's fixture system to override at the test level.

Network mocking works too. The same page.route() API you use in E2E tests intercepts fetch calls from mounted components. You can stub an API response, test a loading state, test an error state — all within a single component test file, no MSW setup required.

Running Tests in CI Without a Display Server

Playwright runs headless by default, so you don't need a virtual display server like Xvfb in most CI environments. GitHub Actions, GitLab CI, and most Docker-based runners handle it fine with the official mcr.microsoft.com/playwright Docker image.

Add a separate script entry in package.json to keep component tests distinct from E2E tests. Something like "test:ct": "playwright test --config playwright-ct.config.ts". That way CI can run them in parallel on different jobs without the configs interfering with each other.

Cache the Playwright browser binaries between CI runs — they're roughly 180MB per browser. On GitHub Actions that's a actions/cache step keyed on your @playwright/test version. Without the cache, every CI run re-downloads Chromium and your pipeline slows down by 2-3 minutes for no reason. On a large project where you're testing 40+ component variants (like the full suite for a component library), that time adds up fast.

What Playwright Component Tests Don't Replace

They don't replace unit tests for pure logic. If you've got a custom hook, a state machine, or a utility function — test that with Vitest or Jest. Fast, no browser overhead. Playwright component tests are for the layer where logic meets rendering.

They also don't replace E2E tests for user flows. Can a user complete checkout? Does navigation work across pages? That's page-level Playwright. Component tests cover one component in isolation. Both are necessary, neither replaces the other.

What's the overhead compared to Vitest? Honest answer: about 3-5x slower per test, because you're launching a real browser process. A Vitest suite of 200 tests might finish in 4 seconds. The same 200 tests as Playwright component tests might take 15-20 seconds. For most teams that's fine — run component tests in a separate CI job and parallelize with --workers=4. The confidence you gain from real browser rendering is worth it.

If you're working with visual tools — like the gradient generator or Tailwind shadow tools — and you want to verify the output CSS is correct in a real browser, Playwright component tests are genuinely the right tool. Not a workaround. The right tool.

Tracing and Debugging Failed Tests

Playwright's trace viewer is the single best debugging tool in this space. When a component test fails, run it with --trace on and you get a recorded timeline of every DOM mutation, network request, and console log. You can scrub through it, inspect the DOM at any point, and see exactly which line of your component code triggered the failure.

Enable traces in your config with use: { trace: 'on-first-retry' } so you're not storing traces for every passing test, only for retried failures. The trace file is a zip you open in npx playwright show-trace trace.zip — no browser extension, no external service.

Console errors from within the component bubble up to page.on('console', ...) — you can assert that no errors were logged, or specifically that a certain warning appeared. This catches things like missing React keys, prop type violations logged as warnings, or any console.error your component fires deliberately when given bad props. It's a level of introspection you simply can't get from jsdom.

FAQ

Is @playwright/experimental-ct-react production-ready despite the 'experimental' name?

Yes, as of Playwright v1.40+. The 'experimental' label in the package name hasn't been updated to reflect stability, but the API has been stable for over a year and it's used in production by large teams. The Playwright team has committed to keeping the API stable going forward.

Can I use Playwright component tests with Next.js App Router components?

Server Components can't be mounted directly because they rely on server-side rendering infrastructure. Client Components work fine. For Server Components you'll need E2E tests or a separate test harness. Most teams test Client Components with Playwright CT and Server Component output with E2E tests against a local Next.js dev server.

How do I mock a module import (like an API client) inside a mounted component?

Use Vite's module mocking via ctViteConfig. Add a resolve.alias entry pointing the module to a mock file, or use vi.mock if you configure Vitest alongside Playwright. Alternatively, inject dependencies as props in tests — it forces better component design and makes mocking trivial without any build config changes.

What's the difference between ctPort and the browser's port in Playwright CT?

The ctPort (default 3100) is the Vite dev server Playwright spins up to serve your component. The browser connects to this port internally. You don't need to open it manually or configure a proxy — Playwright manages the connection. Just make sure port 3100 isn't in use by another process on your machine.

Can I run visual snapshot comparisons with Playwright component tests?

Yes. Use expect(component).toHaveScreenshot('button-primary.png') — Playwright generates a baseline on first run and diffs on subsequent runs. Thresholds are configurable (e.g., maxDiffPixelRatio: 0.05 for 5% tolerance). Store snapshots in git and review diffs in PRs the same way you'd review code changes.

Do Playwright component tests support React 18 concurrent features like Suspense?

Yes, because you're running in a real browser with the actual React runtime. Suspense boundaries, transitions via useTransition, and deferred values via useDeferredValue all work. You can await the resolved state by waiting for a locator: await expect(component.getByText('Loaded')).toBeVisible() with a timeout.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Component Testing in 2027: Visual, Unit, Interaction ComparedChromatic Visual Testing: Catch UI Regressions Before They ShipTesting React with Vitest: Fast Unit Tests for Component LogicNative CSS Nesting: Full Guide with Real Component Examples