How to Set Up Playwright Component Testing in a Vite + React Project (and Ditch Jest)

TL;DR — Playwright component testing replaces Jest + jsdom with real Chromium DOM rendering in a Vite + React project, eliminating polyfills and layout lies. This guide walks through the config, provider wrappers, CSS gotchas, and a sharded GitHub Actions setup that runs 500 tests in under 3 minutes.

Jest and React Testing Library served their time, but jsdom is a lie. It's a half-implemented browser that swears layout works, then silently returns 0 for every getBoundingClientRect call. If you've ever mocked ResizeObserver for the fifth time this quarter, you already know why Playwright component testing is the move for Vite + React projects in 2026.

This is a step-by-step guide to replacing Jest/RTL with Playwright's experimental-ct-react mode: real Chromium DOM, real CSS, real layout, and CI sharding that actually scales. No more jest.mock('canvas') incantations.

  1. Install @playwright/experimental-ct-react and the Playwright browsers.
  2. Create playwright-ct.config.ts pointing at your Vite config.
  3. Add the playwright/index.html and index.tsx mount files.
  4. Wire global CSS imports and provider wrappers inside beforeMount.
  5. Write your first .spec.tsx using mount() instead of render().
  6. Migrate Jest suites file by file, deleting jsdom shims as you go.
  7. Configure GitHub Actions with sharding across 4 workers.
react developer terminal
react developer terminal

Why Should You Replace Jest With Playwright Component Testing?

Playwright component testing renders your React components in a real browser instead of jsdom, which eliminates the entire category of "passes in tests, breaks in production" bugs caused by missing browser APIs. You also get first-class screenshot diffs, tracing, and the same locator API you already use for E2E.

The jsdom tax is real. Every serious React project ends up with a setupTests.ts stuffed with polyfills for IntersectionObserver, matchMedia, scrollTo, and HTMLCanvasElement.prototype.getContext. Playwright throws that file in the bin. If a component uses a browser API, Chromium has it. Done.

The tradeoff: cold start is slower than Vitest, and you can't run 5000 tests per second on your laptop. For component-level correctness in projects where Vitest + jsdom keeps lying to you, that tradeoff is worth it.

What Do You Need to Install?

You need three packages: @playwright/experimental-ct-react, @playwright/test, and the Playwright browser binaries. Install them all in one shot:

pnpm add -D @playwright/experimental-ct-react @playwright/test
pnpm exec playwright install --with-deps chromium

The --with-deps flag matters on Linux CI runners — it installs the system libs Chromium needs. Skip it and your GitHub Action will die with a cryptic libnss3.so error. The docs don't mention this until you're already three failed builds deep.

How Do You Configure playwright-ct.config.ts?

Create a dedicated config file — do not merge it with your E2E Playwright config. Component tests need a different testDir, a different reporter setup, and they use the Vite dev server as the build layer.

// playwright-ct.config.ts
import { defineConfig, devices } from '@playwright/experimental-ct-react';
import { resolve } from 'node:path';

export default defineConfig({
  testDir: './src',
  testMatch: /.*\.spec\.tsx?$/,
  snapshotDir: './__snapshots__',
  timeout: 10_000,
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,
  reporter: process.env.CI ? 'github' : 'html',
  use: {
    trace: 'on-first-retry',
    ctPort: 3100,
    ctViteConfig: {
      resolve: {
        alias: { '@': resolve(__dirname, './src') },
      },
    },
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
});

The ctViteConfig block is where you re-import path aliases, plugins, and anything else from your main vite.config.ts. In practice, most projects can just import the main Vite config and spread it. But be careful: React plugin duplication will throw, so strip @vitejs/plugin-react from the spread first.

How Do You Handle Global CSS and Providers?

You wire global CSS in playwright/index.tsx and providers via the beforeMount hook. This is the single biggest gotcha in the whole migration and it's buried in the Playwright docs.

First, the mount HTML and script — Playwright generates these when you run npx playwright-ct init, but for reference:

// playwright/index.html
<!DOCTYPE html>
<html><head><title>CT</title></head>
  <body><div id="root"></div><script type="module" src="./index.tsx"></script></body>
</html>

// playwright/index.tsx
import { beforeMount } from '@playwright/experimental-ct-react/hooks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import '../src/styles/globals.css';
import '../src/styles/tokens.css';

beforeMount(async ({ App, hooksConfig }) => {
  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  const route = hooksConfig?.route ?? '/';
  return (
    <QueryClientProvider client={client}>
      <MemoryRouter initialEntries={[route]}>
        <App />
      </MemoryRouter>
    </QueryClientProvider>
  );
});

Pass per-test config through hooksConfig when calling mount(). That's how you inject a specific route, a mocked user session, or a themed provider without rebuilding the wrapper for every test.

vscode terminal
vscode terminal

What Does a Real Component Test Look Like?

It looks almost identical to RTL, but with Playwright's locator API and real browser assertions. The mount() function returns a ComponentLocator — chain .locator() or .getByRole() off it exactly like an E2E test.

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

test('renders the plan name and CTA', async ({ mount }) => {
  const component = await mount(
    <PricingCard plan="Pro" price={29} onSelect={() => {}} />
  );
  await expect(component.getByRole('heading', { name: 'Pro' })).toBeVisible();
  await expect(component.getByText('£29/mo')).toBeVisible();
});

test('fires onSelect when the CTA is clicked', async ({ mount }) => {
  let clicked = false;
  const component = await mount(
    <PricingCard plan="Pro" price={29} onSelect={() => { clicked = true; }} />
  );
  await component.getByRole('button', { name: /choose pro/i }).click();
  expect(clicked).toBe(true);
});

Note the closure trick for callbacks — Playwright serialises props across the browser boundary, so you can't pass a Jest vi.fn() and expect it to record calls. Use a plain closure or the built-in page.exposeFunction for anything more complex.

How Do You Set Up CI with Sharding?

Split tests across parallel jobs with the --shard flag. Playwright will divide test files evenly and each job runs a slice. For a mid-size component suite, 4 shards on GitHub Actions cuts wall time from ~8 minutes to under 3.

# .github/workflows/ct.yml
name: Component Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1/4, 2/4, 3/4, 4/4]
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'pnpm' }
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps chromium
      - run: pnpm exec playwright test -c playwright-ct.config.ts --shard=${{ matrix.shard }}
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: trace-${{ strategy.job-index }}
          path: test-results/

Pair this with Vercel preview deployments if you also want visual regression against staging, and pipe failure traces to Sentry for reviewer context. The trace viewer alone justifies the switch — you get a step-by-step DOM timeline for every failed test, not a cryptic stack trace.

What Are the Real Gotchas?

Three things will bite you. First, CSS-in-JS libraries that inject styles at runtime (styled-components, emotion) work fine but flash unstyled content in screenshots — add a waitForFunction for your theme class before snapshotting. Second, portals render outside the mounted component, so component.getByRole() won't find modal content; use page.getByRole() instead. Third, date/time mocking requires page.clock now (Playwright 1.45+) — vi.useFakeTimers equivalents don't exist.

Also: don't try to run this in the same project as Vitest without careful testMatch patterns. Both will happily glob-match *.spec.tsx and fight each other. Rename Playwright tests to *.ct.tsx or keep them under tests/ct/.

The Verdict

Playwright component testing is the correct default for any Vite + React project starting in 2026 where component correctness matters more than raw test throughput. You lose Vitest's blazing speed on pure logic tests, so keep Vitest for utils and hooks — but let Playwright own anything that touches the DOM. The jsdom era is over, and good riddance.

FAQs

Is Playwright component testing production-ready?

Yes, though it's still marked experimental. It's been stable since Playwright 1.30 and is used in production by Adobe, Microsoft, and several large React shops. The API may still see minor tweaks, but breaking changes are rare and well-documented in release notes.

Can I run Playwright component tests alongside Vitest?

Yes, and it's the recommended hybrid. Use Vitest for pure functions, hooks, and reducers where speed matters. Use Playwright CT for anything that renders DOM, uses browser APIs, or relies on CSS layout. Just keep the file naming conventions distinct so the runners don't collide.

How much slower is Playwright than Jest for component tests?

Roughly 3-5x slower per test on cold start, but sharding closes most of the gap in CI. A 500-test suite that ran in 45 seconds under Jest with jsdom typically finishes in 2-3 minutes on Playwright with 4-way sharding. The reliability gains offset the wall-clock cost.

Do I need to rewrite my React Testing Library tests?

Mostly yes, but the mental model transfers directly. RTL's getByRole, getByText, and getByLabelText map one-to-one onto Playwright's locators. The main rewrites are around fireEvent (becomes .click(), .fill()) and async assertions (native await expect(...).toBeVisible() replaces waitFor).

Does Playwright component testing work with Next.js?Not natively — the CT runner uses Vite under the hood, so Next.js apps need a workaround where components are extracted into a Vite-buildable package. For Next.js projects, Playwright's E2E mode against a running dev server is the more practical path.

H
Hiten

Senior Frontend Engineer & Architect. 15+ years building fast, accessible web platforms. More at hiten.dev