Playwright Component Testing | React, Vue, Svelte, MSW & CI

Key takeaways

Playwright Component Testing renders isolated components in a real browser using the same locator and assertion model as E2E. This guide covers framework setup, how the CT bundling step works, which props can cross from the test process into the browser, MSW integration, visual regression, and CI sharding, and when to keep Vitest for speed.

Playwright Component Testing (CT) lets you mount UI components in a real browser and drive them with the same locators, assertions, and trace tooling you already use for end-to-end tests. The payoff is that you catch layout, CSS, focus, and browser API issues that jsdom-based unit tests miss, while keeping each test much smaller than a full application E2E flow.

If your team already uses Playwright for E2E, the strongest argument for CT is consolidation: one selector model, one assertion library, one trace viewer, and one CI setup for both layers. This guide covers React, Vue, and Svelte setups, how the bundling step works, how to wire MSW, when visual regression is worth it, and how to run the suite in CI.

Stability note: CT ships as @playwright/experimental-ct-* packages and is still labelled experimental. Pin versions, read the release notes on each upgrade, and pilot on a design-system or shared UI package before adopting it across an organization.

Role in the testing pyramid

A useful mental model is the testing pyramid with an explicit component (browser) layer between fast Node tests and full E2E.

LayerTypical toolingWhat it optimizes for
Unit / integration (Node)Vitest, Jest + Testing LibrarySpeed, pure logic, hooks with mocks
Component (real browser)Playwright CT, Cypress CT, Vitest browser modeLayout, real events, focus rings, ResizeObserver
End-to-endPlaywright, CypressRouting, real backends, cookies, cross-page flows

Component tests target widget-sized surfaces: a date picker, a data table row, a checkout summary, not the entire app shell. They answer “does this component render and behave correctly in a browser, given controlled inputs?” E2E answers “can users actually complete the journey across our real system?” The two fail for different reasons, so you keep both.

The browser layer earns its cost for design systems, complex CSS (grids, container queries, animations), pointer, focus and keyboard behavior, and components that depend on browser-only APIs such as matchMedia, IntersectionObserver, crypto.subtle, or print styles. Heavy business logic, pure reducers, and most hook-only tests are better left in Vitest with jsdom, where DOM fidelity adds little and feedback is much faster.

The diagram below places Playwright CT in a typical delivery pipeline: fast feedback in development, CT on pull requests, E2E on critical paths.

flowchart TB
  subgraph dev["Local dev"]
    U[Vitest / RTL] -->|fast loops| PR
    CT1[Playwright CT] -->|targeted| PR
  end
  subgraph ci["CI"]
    PR[Pull request] --> UCI[Unit + coverage]
    PR --> CICT[CT suite]
    PR --> E2E[Subset E2E / smoke]
  end
  subgraph release["Release"]
    E2EFull[Full E2E / nightly] --> Staging
    Staging --> Prod[Production + monitoring]
  end
  CICT --> E2EFull

Architecture: mount and the Vite build step

Component tests in Playwright do not render React inside Node. Before any test runs, the CT runner scans your test files for the components they import, builds one browser bundle containing those components and the playwright/index.* entry with Vite, and caches the result under playwright/.cache. Each test then opens a page that loads this prebuilt bundle, and mount asks the page to render a specific registered component with the props you supplied.

sequenceDiagram
  participant Test as Test file (Node)
  participant Runner as CT runner
  participant Vite as Vite build
  participant Browser as Browser page
  Runner->>Vite: collect imported components, build once
  Vite-->>Runner: bundle cached in playwright/.cache
  Test->>Runner: mount(component, props)
  Runner->>Browser: load bundle, send component ref + serialized props
  Browser-->>Test: Locator for the mounted root

Two practical consequences follow. First, a component that is only referenced indirectly (built from a string, for example) will not be in the bundle, so import components statically in the spec. Second, if the cache looks stale after a config change, deleting playwright/.cache forces a rebuild.

Running npm init playwright@latest -- --ct scaffolds three things:

  • playwright/index.html, a small HTML shell that hosts the mount root.
  • playwright/index.tsx (or .ts for Vue/Svelte), the framework entry where you import global styles and register beforeMount/afterMount hooks. It plays the role of your app’s main.tsx, but for tests.
  • playwright-ct.config.ts, which sets testDir, timeouts, projects, and CT-specific options such as ctViteConfig under use.

The mount fixture renders your component into that shell and returns a Playwright Locator. You keep using getByRole, click, toHaveScreenshot, and the rest of the test API exactly as in E2E.

What can cross from the test into the browser

Props travel from the Node test process to the browser, so they must be serializable: strings, numbers, booleans, arrays, plain objects, and Date-like JSON data. A class instance, a live client object, or a proxy will not arrive intact. Functions are the exception that CT handles specially: a callback passed as a prop (for example onClick={() => clicked = true}) is replaced in the browser by a stub that calls back into the test process, which is what makes event assertions possible. That round trip is asynchronous, so assert on callbacks with expect.poll or after a UI assertion, not synchronously.

Anything that is not serializable, such as a QueryClient or a router, belongs inside the browser bundle: create it in a wrapper component or in the beforeMount hook rather than in the spec.

Your app’s Vite resolve aliases and CSS pipeline (PostCSS, Tailwind) must also be applied in ctViteConfig. Otherwise imports like @/components fail to resolve, or components render unstyled even though they look fine in Storybook.

Setup: React, Vue, and Svelte

Package names and defaults change between releases, so check the official Playwright CT docs for the version you install.

React (@playwright/experimental-ct-react)

After npm init playwright@latest -- --ct (choose React), wrap every mounted component in your providers from playwright/index.tsx. In the React adapter, beforeMount receives the component as App and returns the tree to render; per-test options arrive as hooksConfig.

// playwright/index.tsx
import { beforeMount } from '@playwright/experimental-ct-react/hooks';
import { ThemeProvider } from '../src/theme/ThemeProvider';
import '../src/styles/global.css';

export type HooksConfig = { theme?: 'light' | 'dark' };

beforeMount<HooksConfig>(async ({ App, hooksConfig }) => {
  return (
    <ThemeProvider theme={hooksConfig?.theme ?? 'light'}>
      <App />
    </ThemeProvider>
  );
});

A spec can then choose the theme per test with mount(<Button />, { hooksConfig: { theme: 'dark' } }). The value passes through the same serialization boundary, so keep it to plain data.

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

export default defineConfig({
  testDir: './src',
  testMatch: '**/*.ct.spec.{ts,tsx}',
  timeout: 30_000,
  reporter: process.env.CI ? [['blob']] : [['html', { open: 'on-failure' }]],
  use: {
    trace: 'on-first-retry',
    ctPort: 3100,
    // CT-specific Vite options live under `use`, not at the top level
    ctViteConfig: {
      resolve: {
        alias: {
          // keep this in sync with vite.config.ts
          '@': fileURLToPath(new URL('./src', import.meta.url)),
        },
      },
    },
  },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});

fileURLToPath matters on Windows, where new URL(...).pathname produces /C:/... and the alias silently fails to resolve.

Vue (@playwright/experimental-ct-vue)

In the Vue adapter, beforeMount receives the Vue app instance, so you install plugins on it directly.

// playwright/index.ts
import { beforeMount } from '@playwright/experimental-ct-vue/hooks';
import { createMemoryHistory, createRouter } from 'vue-router';
import { createPinia } from 'pinia';
import { h } from 'vue';
import { i18n } from '../src/i18n';

beforeMount(async ({ app }) => {
  const router = createRouter({
    history: createMemoryHistory(),
    routes: [
      { path: '/', name: 'home', component: { render: () => h('div', 'ct') } },
    ],
  });
  app.use(createPinia());
  app.use(i18n);
  app.use(router);
});

The stub route exists because components that call useRouter() or render RouterLink need an installed router; add routes matching the RouterLink targets your specs touch, or you will see “no match for location” warnings.

Svelte (@playwright/experimental-ct-svelte)

Check that the CT package version supports your Svelte major (Svelte 5 support arrived later than Svelte 4), and keep Svelte, Vite, and the CT package on compatible versions. Global styles go in the entry module, and specs mount with mount(Component, { props: { ... } }).

Across all three frameworks, keep a single source of truth for aliases and CSS: either import a shared Vite config into ctViteConfig or review the duplicated settings on every Vite major upgrade. For selectors, prefer roles and accessible names and reserve data-testid for markup without a meaningful role.

Code examples

The examples below assume the setup above; adjust imports to your module layout.

Basic component test (React)

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

test('primary button activates from the keyboard', async ({ mount, page }) => {
  let clicks = 0;
  await mount(
    <Button variant="primary" onClick={() => { clicks += 1; }}>
      Save
    </Button>
  );
  const btn = page.getByRole('button', { name: 'Save' });

  await page.keyboard.press('Tab');
  await expect(btn).toBeFocused();

  await page.keyboard.press('Enter');
  // the callback runs in the test process via an async round trip
  await expect.poll(() => clicks).toBe(1);
});

The test tabs to the button rather than asserting focus right after mount, because nothing focuses a freshly mounted button unless it uses autoFocus. Focus order, :focus-visible, and the difference between Enter and Space activation are exactly the behaviors jsdom does not model faithfully, which is why this test belongs in a real browser.

Props and user events (Vue)

Treat props as inputs and observable results (DOM text, ARIA state, emitted events) as outputs.

// Counter.ct.spec.ts
import { test, expect } from '@playwright/experimental-ct-vue';
import Counter from './Counter.vue';

test('increments visible count by step', async ({ mount, page }) => {
  await mount(Counter, { props: { step: 2 } });
  await page.getByRole('button', { name: 'add' }).click();
  await expect(page.getByTestId('count')).toHaveText('2');
});

Colocating *.ct.spec.ts files next to the SFC keeps ownership aligned with the component and keeps a feature branch’s diff in one directory.

Context and providers (React)

Do not try to pass a QueryClient from the test. Create it inside a wrapper component that is part of the browser bundle.

// src/test/withProviders.tsx
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

export function TestProviders({ children }: { children: React.ReactNode }) {
  // useState so the client survives re-renders; creating it in the render body
  // would make a fresh, empty cache on every render
  const [client] = useState(
    () => new QueryClient({ defaultOptions: { queries: { retry: false } } })
  );
  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
// UserCard.ct.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { UserCard } from './UserCard';
import { TestProviders } from '../test/withProviders';

test('renders the fetched user name', async ({ mount, page }) => {
  await mount(
    <TestProviders>
      <UserCard userId="42" />
    </TestProviders>
  );
  await expect(page.getByRole('heading', { name: 'Ada' })).toBeVisible();
});

retry: false matters here: with React Query’s default of three retries and exponential backoff, a mocked 404 would keep the component in a loading state long enough to hit the test timeout. If a provider also needs a router, add a memory router in beforeMount for CT only.

MSW: shared handlers

MSW lets Storybook, unit tests, and CT share one description of the network contract.

// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/user/:id', ({ params }) => {
    if (params.id === '42') {
      return HttpResponse.json({ id: '42', name: 'Ada' });
    }
    return new HttpResponse(null, { status: 404 });
  }),
];

Recent Playwright versions (1.46 and later) provide a router fixture in CT that accepts MSW handlers directly, routing the page’s requests through them without a service worker:

import { test, expect } from '@playwright/experimental-ct-react';
import { http, HttpResponse } from 'msw';
import { handlers } from '../mocks/handlers';
import { UserCard } from './UserCard';
import { TestProviders } from '../test/withProviders';

test('shows an error state on 500', async ({ mount, page, router }) => {
  await router.use(...handlers);
  // as with MSW's use(), handlers added later take precedence
  await router.use(http.get('/api/user/:id', () => new HttpResponse(null, { status: 500 })));
  await mount(<TestProviders><UserCard userId="42" /></TestProviders>);
  await expect(page.getByRole('alert')).toBeVisible();
});

The alternative is to start a real MSW worker in playwright/index.tsx. That path requires the mockServiceWorker.js file (generated by npx msw init <dir>) to be served by the CT Vite server, which usually means pointing ctViteConfig.publicDir at that directory, and you must make sure worker.start() runs only once per page:

// playwright/index.tsx
import { beforeMount } from '@playwright/experimental-ct-react/hooks';
import { setupWorker } from 'msw/browser';
import { handlers } from '../src/mocks/handlers';

const worker = setupWorker(...handlers);
let started: Promise<unknown> | undefined;

beforeMount(async ({ App }) => {
  started ??= worker.start({ onUnhandledRequest: 'bypass', quiet: true });
  await started;
  return <App />;
});

Whichever you choose, use one convention per repository and document it. Ad hoc page.route calls scattered through specs make it hard to answer “who mocks this endpoint?” When a mock does not take effect, the cause is usually a path mismatch (trailing slash, a different base URL) or a worker that never finished starting.

Visual regression (screenshots)

toHaveScreenshot on a locator works well for design-system components, but fonts and sub-pixel rendering differ between operating systems.

test('Button visual baseline', async ({ mount, page }) => {
  const root = await mount(<Button>Hello</Button>);
  await page.evaluate(async () => {
    await document.fonts.ready;
  });
  await expect(root).toHaveScreenshot('button-hello.png', {
    maxDiffPixels: 20,
  });
});

toHaveScreenshot already disables CSS animations by default and retries until two consecutive captures match, so most flakiness comes from fonts, sub-pixel layout, and non-deterministic content. Wait for web fonts, use mask for regions such as timestamps, and set maxDiffPixels or threshold after inspecting a real diff image rather than guessing. Playwright stores baselines per platform (file names carry a -linux or -darwin suffix), so baselines generated on a developer’s Mac are not used on a Linux CI runner; generate them in the same container image that CI uses, for example the official mcr.microsoft.com/playwright image.

Keep the visual set small and curated. Screenshotting every component turns every intentional style change into a batch of baseline updates, and tools like Chromatic on top of Storybook are often a better fit for broad visual coverage.

beforeMount: theme, i18n, routers

beforeMount is where CT mirrors production closely enough to be meaningful: theme tokens and global CSS, i18n with a fixed test locale, and routers or query clients with test doubles. It runs before every mount, so keep it light. Setup that only two specs need is better placed in a wrapper component used by those specs.

Framework-specific notes

ConcernReactVueSvelte
StateHooks and context; lift providers into beforeMountComposition API, Pinia installed in beforeMountStores or runes plus component props
AsyncNo act() needed; await UI outcomes with web-first assertionsSame; no flushPromisesSame; no tick()
StylingCSS modules, Tailwind, etc. need Vite pipeline paritySameSame, plus Svelte scoped CSS

Because Playwright assertions retry until they pass or time out, the framework-specific “flush” helpers from unit testing (act, flushPromises, tick) are not needed in CT. Assert on what the user can observe. If you need to test a custom hook or a store in isolation, that remains a Vitest job; reach for CT when the integration of component and state is what you distrust.

A reasonable default is to write new tests in Vitest and move to Playwright CT when the failure you want to catch depends on real layout, accessibility behavior, ResizeObserver, or real input events.

CI: GitHub Actions with sharding

name: component-tests

on:
  pull_request:
  push:
    branches: [ main ]

concurrency:
  group: ct-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  ct:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2]
        total: [2]
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium

      - name: Run component tests
        run: npx playwright test -c playwright-ct.config.ts --shard=${{ matrix.shard }}/${{ matrix.total }}

      - name: Upload blob report
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@v4
        with:
          # artifact names must be unique per shard in upload-artifact v4
          name: blob-report-${{ matrix.shard }}
          path: blob-report
          retention-days: 7

A few details in this workflow matter:

  • With upload-artifact@v4, two shards uploading an artifact with the same name fail, so the shard number is part of the name.
  • The config above switches to the blob reporter on CI. A follow-up job can download all blob-report-* artifacts and run npx playwright merge-reports --reporter html ./all-blob-reports to produce a single HTML report with traces.
  • Locally the HTML reporter opens on failure; on CI that would block, which is another reason to pick the reporter based on process.env.CI.
  • Installing only Chromium keeps the job fast. Add Firefox and WebKit projects in a nightly run if you need cross-browser coverage.
  • For a small UI package, one shard is enough. Sharding pays off once the suite runtime outweighs the fixed cost of each runner (checkout, install, browser download, and the Vite build).

Common CT traps

An unstyled component almost always means the CT Vite config differs from the app’s Vite config. Copy the PostCSS/Tailwind setup and plugins into ctViteConfig, and import global CSS from playwright/index.*. If Storybook looks right and CT does not, compare the two configurations line by line.

Import errors such as @/foo not resolving are the same class of problem: add matching resolve.alias entries, and clear playwright/.cache if the old build keeps being used.

Errors on mount that mention serialization are almost always caused by passing a class instance, a client object, or a proxy from the test. Pass plain data, create the object in a wrapper inside the bundle, or use callbacks.

Slow pull-request checks usually come from either too many CT tests (move low-value cases back to Vitest) or a heavy global beforeMount. Barrel files that re-export an entire library also inflate the CT bundle, because every component imported by any spec, and everything it imports, has to be built.

Coverage is a separate concern: the CT browser bundle is not the Node test bundle, and merging Istanbul output from CT with Vitest coverage is sensitive to tool versions. Letting Vitest own line coverage for logic and using CT for UI behavior avoids most of that friction. If you need one merged number, give it a dedicated CI job.

Folder layout in a monorepo

packages/
  ui/
    src/
      components/
        Button/
          Button.tsx
          Button.module.css
          Button.ct.spec.tsx
      mocks/
        handlers.ts
      test/
        withProviders.tsx
    playwright/
      index.tsx
    playwright-ct.config.ts
    package.json
  app/
    src/
      ...

In this layout packages/ui runs CT and Storybook, and the app consumes the package. CT guards low-level visuals and interaction, while the app’s E2E suite still validates routing and real APIs. The shared handlers.ts is imported both by Storybook’s preview and by the CT setup, so the same network contract is exercised in both places.

Drawing the line between CT and E2E

CT asks whether a component, given controlled props and providers, looks and behaves correctly in a real browser. E2E asks whether a user can complete a journey with real routing, authentication, and services. Use CT to stop design-system regressions early and E2E to stop product regressions; the layers overlap only partly, which is why you keep both.

References