본문으로 건너뛰기
Previous
Next
Playwright Component Testing | React, Vue, Svelte, MSW & CI

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

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

이 글의 핵심

Playwright Component Testing renders isolated components in a real browser using the same locator and assertion model as E2E. This English guide follows the Korean deep-dive: framework setup, mount vs jsdom, MSW integration, visual regression, CI—and explains the CT bundling pipeline, serialization limits on props, 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/debug tooling you already use for end-to-end tests. The payoff is simple: you catch layout, CSS, focus, and browser API issues that JSDOM-based unit tests miss, while keeping tests more localized than full application E2E flows.

This is not a feature tour written from release notes. It is the setup we kept after a few false starts (wrong Vite config, “works in Storybook” syndrome, and MSW that only half-started). I am biased: if you are already on Playwright for E2E, consolidating on one automation model is worth more than the hot new JS testing trend of the week.

This guide is written for teams shipping React, Vue, or Svelte front ends who want a practical, production-oriented setup: how the bundler pipeline works, how to wire MSW, when to add visual regression, and how to run everything reliably in CI.

Stability note: Playwright’s CT entry points live under @playwright/experimental-ct-* and the feature is still positioned as experimental in the ecosystem. Pin versions, read release notes each upgrade, and pilot on a design-system or shared UI package before org-wide adoption.

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: “Do users actually complete the journey across our real system?” Keep both; they fail for different reasons.

When the browser layer is worth the cost: design systems, complex CSS (grids, container queries, animations), pointer/focus/keyboard behavior, and components that lean on browser-only APIs (matchMedia, Intl, crypto.subtle, print styles). When to stay in Vitest + JSDOM: heavy business logic, pure reducers, most hook-only tests where DOM fidelity adds little.

The diagram below situates Playwright CT in a typical delivery pipeline: fast feedback in development, browser CT on PRs, E2E on critical paths, and production observability as a separate concern.

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 bundling pipeline

Component tests in Playwright are not “import React in Node and hope.” The runner builds a browser bundle for each test (and its mounted tree) and loads it in a real browser process—Chromium is the most common default in CI, and you can run multi-browser projects if your org requires it.

At a high level, the pipeline looks like this:

sequenceDiagram
  participant Test as Test file
  participant Runner as Playwright CT runner
  participant Vite as Vite (ctViteConfig)
  participant Browser as Browser page
  Test->>Runner: import component + call mount
  Runner->>Vite: bundle test + entry + component
  Vite-->>Runner: HMR-friendly module graph
  Runner->>Browser: navigate + inject bundle
  Browser-->>Test: mount result as locator

Scaffold (what npm init playwright@latest -- --ct creates, conceptually):

  • A small test shell (playwright/index.html) that hosts the mount root.
  • A framework entry (playwright/index.tsx / index.vue / Svelte entry) where you register global styles, i18n, routers, and beforeMount hooks—the equivalent of your app’s main.tsx, but for tests.
  • A playwright-ct.config.ts (or playwright.config.ts with testMatch for *.spec.tsx) that sets ctViteConfig, testDir, timeouts, and projects.

The mount fixture renders your component into that shell. The value returned from mount is treated like a root locator in many setups: you still use getByRole, getByTestId, click, screenshot, and the rest of the Playwright test API. That API consistency (same selectors and assertions as E2E) is the main reason teams already on Playwright E2E consolidate here.

Serialization rule (critical): props passed from the test process into the browser must be structured-clone friendly. You cannot smuggle a live class instance, a function from Node (except where the test harness explicitly supports callbacks), or a non-serializable object across the boundary and expect stable behavior. Patterns that work:

  • Pass plain objects, strings, numbers, arrays of JSON-like data.
  • Wrap context using test-only wrapper components in the beforeMount hook (see below) instead of passing non-serializable things as props.
  • For imperative handles, prefer DOM events or refs exposed via callbacks that are created in the browser bundle, not imported from test runner code in fragile ways.

Bundling and path aliases: mirror your app’s Vite (or build tool) resolve aliases in ctViteConfig (resolve.alias) so imports like @/components resolve the same in CT as in vite.config.ts. CSS pipelines (PostCSS, Tailwind) should also be applied in the CT Vite config; otherwise you will see “unstyled” components that pass in Storybook but fail or look wrong in CT.

Step-by-step setup: React, Vue, and Svelte

Below are minimal, realistic setup sketches. Always align versions with the official Playwright CT docs—package names and defaults evolve.

React (@playwright/experimental-ct-react)

  1. Install the experimental CT package for React plus devDependencies you already use (TypeScript, Vite).
  2. Initialize Playwright for CT (npm init playwright@latest -- --ct or add configs manually).
  3. Ensure playwright/index.tsx wraps your beforeMount (theme, i18n, providers).
  4. Point ctViteConfig at the same resolve and plugins (React plugin) as the app, or share a vite.config merge helper.

playwright/index.tsx (illustrative):

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

beforeMount(async ({ hooks }) => {
  // "test" theme — if your prod theme name differs, this is the #1 'why is CT dark mode wrong' bug
  hooks.addHook((children) => (
    <ThemeProvider theme="test">{children}</ThemeProvider>
  ));
});

playwright-ct.config.ts (illustrative skeleton):

import { defineConfig, devices } from '@playwright/experimental-ct-react';

export default defineConfig({
  testDir: './src',
  testMatch: '**/*.ct.spec.{tsx,jsx}',
  timeout: 30_000,
  use: {
    ...devices['Desktop Chrome'],
    trace: 'on-first-retry',
  },
  /* Mirror app bundler settings */
  ctViteConfig: {
    resolve: {
      alias: {
        // Boring, until it is not: keep this 1:1 with vite.config or prepare for cryptic 404s
        '@': new URL('./src', import.meta.url).pathname,
      },
    },
  },
});

Vue (@playwright/experimental-ct-vue)

  1. Use @playwright/experimental-ct-vue and a Vue 3 SFC test pattern.
  2. Register global components and Pinia (if used) in playwright/index.ts with beforeMount.
  3. Share Vite + Vue plugin config via ctViteConfig.

playwright/index.ts (illustrative):

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);
  // Yes, a stub route — your real components will whine if useRouter() has nowhere to go
});

(Add more routes to match RouterLink targets in your specs; a minimal in-memory vue-router setup avoids “no match for location” when components call useRouter().)

Svelte (@playwright/experimental-ct-svelte)

  1. Use @playwright/experimental-ct-svelte for Svelte 4/5 per your project’s version—keep Svelte, Vite, and the CT package on compatible semver lines.
  2. Put global styles in the entry module; mount Svelte components in tests via mount(Component, { props }) patterns from the official examples.

Cross-cutting checklist for all three:

  • One source of truth for aliases and CSS (shared Vite config or explicit duplication that you review on every Vite major).
  • Stable selectors: prefer roles/labels; reserve data-testid for leaf nodes or legacy markup.
  • TypeScript path mapping duplicated in ctViteConfig if you do not import a shared Vite file.

Code examples: patterns you will use in production

The following examples are educational; trim imports to your app’s module layout. They show how to think about each concern more than a drop-in file.

Basic component test (React)

Button.ct.spec.tsx:

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

test('primary button is keyboard-activatable', async ({ mount, page }) => {
  const component = await mount(<Button variant="primary">Save</Button>);
  await expect(component).toBeVisible();
  const btn = page.getByRole('button', { name: 'Save' });
  // In real life you'd tab to this — I left focus asserted tight for a tiny example; adjust to your a11y flow
  await expect(btn).toBeFocused();
  await page.keyboard.press('Enter');
  // assert side effect: toast, aria-live region, or whatever your team actually checks
});

Why a real browser here: focus outlines, :focus-visible, and Enter vs Space behavior are not trustworthy in JSDOM. CT catches issues that are trivial visually but high-impact for accessibility.

Props and user events (Vue SFC + Pinia or local state)

Test props as inputs and emitted events / store commits as outputs. In CT, you usually assert observable results: DOM text, ARIA, or a data attribute the component sets when the store updates.

Counter.ct.spec.ts (sketch):

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

test('increments visible count', async ({ mount, page }) => {
  await mount(Counter, { props: { step: 2 } });
  // sorry, testid — in CT I reach for it when the visible label is a mess
  await page.getByRole('button', { name: 'add' }).click();
  await expect(page.getByTestId('count')).toHaveText('2');
});

Production insight: for Vue, colocate *.ct.spec.ts next to the SFC in large teams so ownership matches Storybook or unit tests, and the diff on a feature branch stays localized.

Context and provider integration (React)

Avoid passing a non-serializable “client” from the test runner. Instead, mount a wrapper in beforeMount (global) or inline in the spec (local) for one-off cases.

withProviders.tsx (test helper):

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

export function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });
}

export function TestProviders({ children }: { children: React.ReactNode }) {
  const client = createTestQueryClient();
  return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

UserCard.ct.spec.tsx (using wrapper mount pattern available in your CT package version):

import { test, expect } from '@playwright/experimental-ct-react';
import { UserCard } from './UserCard';
import { TestProviders } from '../testUtils/withProviders';

test('shows cached name from react-query', async ({ mount, page }) => {
  await mount(
    <TestProviders>
      <UserCard userId="42" />
    </TestProviders>
  );
  await expect(page.getByRole('heading', { name: 'Ada' })).toBeVisible();
});

When this breaks: if your providers assume routers or location, add a small memory router (React Router) in beforeMount for CT only. Keep the router scoped to tests so you do not reimplement the whole app.

MSW: shared handlers, aligned with the app

MSW (Mock Service Worker) keeps network contracts consistent across Storybook, unit tests, and CT. The two common patterns in Playwright CT are:

  1. Start MSW in the test shell (Playwright’s CT hooks / msw browser integration)—handlers live in a single module imported by playwright/index.*.
  2. Playwright’s network routing (lower-level) when you need per-test one-offs—use sparingly to avoid a fragmented style.

src/mocks/handlers.ts (excerpt):

import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/user/:id', ({ params }) => {
    // our team always uses '42' for happy-path demos — commit a constant if this annoys you
    if (params.id === '42') {
      return HttpResponse.json({ id: '42', name: 'Ada' });
    }
    return new HttpResponse(null, { status: 404 });
  }),
];

playwright/index.tsx (start worker once; exact MSW v1/v2 APIs depend on your version—consult MSW + Playwright CT docs for your stack):

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

const worker = setupWorker(...handlers);

beforeMount(async () => {
  // double-starting the worker is how you get the 'it works on my machine' CI meltdown
  if (!window.__msw_ct_started) {
    await worker.start({ onUnhandledRequest: 'bypass' });
    window.__msw_ct_started = true;
  }
});

(Declare __msw_ct_started on window in a small global.d.ts for TypeScript, or use a module singleton pattern.)

Rule of thumb: one convention per repo—either MSW everywhere for HTTP in CT, or page.route for rare edge cases documented in your testing README. Mixing ad hoc routes in random tests is how teams lose discoverability of “who mocks this endpoint?”

Visual regression (screenshots)

toHaveScreenshot on a locator or the page is effective for design-system components, but fonts and subpixel differences across OS and CI will bite you. Mitigate with:

  • Deterministic base styles in CT (no reliance on “whatever font the OS had”).
  • await page.evaluateHandle(() => document.fonts.ready) when custom fonts load.
  • Tolerances (maxDiffPixels, threshold) set intentionally after comparing baselines in CI and locally.
test('Button visual baseline', async ({ mount, page }) => {
  const root = await mount(<Button>Hello</Button>);
  // fonts are the silent killer of screenshot tests — always wait
  await page.evaluate(() => document.fonts.ready);
  await expect(root).toHaveScreenshot('button-hello.png', {
    maxDiffPixels: 20, // tune on purpose; 0 is a fantasy on Linux vs macOS baselines
  });
});

Production insight: for large libraries, keep a small, curated set of visual CT tests. Honestly, I think screenshot-every-component in CT is overhyped—it is a maintenance tax. Everything else is often better in Chromatic/Storybook or E2E smoke visuals to control cost and flake.

beforeMount hooks: i18n, theme, and routers

beforeMount is where you make CT mirror production enough to be meaningful, without bootstrapping the whole app:

  • Theme tokens and global CSS imports.
  • i18n with a test locale and deterministic message catalogs.
  • Routers and query clients with test doubles.

Keep hooks fast: heavy global setup on every test slows PR feedback. If something is only needed in two specs, colocate a local wrapper instead of making every test pay the cost.

Framework-specific patterns

ConcernReactVueSvelte
StateHooks + context; lift providers to beforeMountComposition API, PiniaStores (writable, et al.) + component props
Asyncact is less central in CT; await UI outcomesflushPromises patterns when needed (Vue test utils background)tick() from svelte in unit tests; in CT often await DOM
StylingCSS modules, Tailwind, etc. need Vite pipeline paritysamesame + Svelte scoped CSS
  • React hooks: prefer user-observable outcomes (text, ARIA) over reaching into useState internals. Where you must assert against a custom hook, that remains a Vitest concern.
  • Vue Composition API: colocate *.ct.spec with the SFC; for emitted events, assert on the real DOM after interaction.
  • Svelte stores: import store-driven components and drive them through UI interactions; for pure store tests, keep Vitest for speed and use CT when the component + store integration is risky.

I used to keep giant comparison tables in these posts. This changed everything for our team when we stopped doing that: we defaulted new tests to Vitest, reached for Playwright CT when failures were browser-realistic (layout, a11y, ResizeObserver, real input), and left Cypress vs Playwright debates to the people paying the invoices. The matrix does not run your CI.

CI/CD: GitHub Actions example (complete sketch)

Goals: install only the browsers you need, cache dependencies, run CT with retries + reporting, and upload artifacts (HTML report, traces) for failed PRs.

# Copy-paste friendly: trim matrix to one shard until you have a real reason to pay for parallelism
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
    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 }}
        env:
          CI: true

      - name: Upload Playwright report
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report

    strategy:
      fail-fast: false
      matrix:
        total: [2]
        shard: [1, 2]

Adjustments for real repos:

  • Matrix sharding scales linearly until the runner count hurts; for small UI packages, a single shard is fine.
  • Add PLAYWRIGHT_HTML_REPORT_OPEN: never (or the equivalent flag) so CI does not try to open a browser.
  • Pin Chromium only if you do not need Firefox/WebKit for CT—most teams start with Chromium in CI for speed, and optionally run all browsers nightly.

Best practices (short list)

After working with this for 6 months, here is what I learned, distilled into a table so I cannot weasel out of it later.

Best practiceWhat “good” looks like in practice
One bundler storyctViteConfig mirrors app aliases, PostCSS, and Tailwind; no “mystery unstyled” components.
Stable selectorsPolicy-level: role/name first, data-testid as an escape hatch for gnarly markup.
Serializable propsPush providers and routers into beforeMount and wrapper components—do not shove class instances through mount.
MSW as a contractOne obvious place for handlers; avoid a pile of ad hoc page.route calls nobody can grep.
Scope visual testsCurated set, fonts waited on, maxDiffPixels chosen on purpose.
Pin experimental CTLockfile + read release notes; upgrades are when fun happens.
Keep most tests in VitestCT where fidelity pays; colocate specs and code-split helpers so you do not bundle half the app per test.
CI hygieneShard when needed, on-first-retry traces, no screenshots on every green run.

A few of those rows are perf in disguise: colocating CT specs, avoiding fat barrel imports, and keeping beforeMount lean are what stopped our PRs from feeling like a second job.

Common pitfalls I have hit

The blank, “why is this unstyled” component almost always means the CT Vite config is not your app Vite config. I have burned hours on that. Copy the PostCSS pipeline and css.postcss bits, add the same plugins, and make sure global CSS is imported from playwright/index.*. If Storybook is pretty and CT is not, you are not done.

@/foo import explosions are the same class of bug: add matching resolve.alias (and restart—yes, really). I treat alias drift as a code smell: when vite.config and playwright-ct diverge, someone will pay in CI.

Serialization errors on mount are rarely “Playwright is broken.” They are you passing a function, a class instance, or some clever proxy from the test runner. Fix: plain JSON-ish props, or wrappers created in the browser bundle, or events for imperative seams.

Flaky toHaveScreenshot is not a mystic curse; it is fonts, subpixel math, and animation. I wait for document.fonts.ready, pause or disable animations in CT where possible, and mask non-deterministic pixels. I tune maxDiffPixels after looking at a real diff image, not by vibes.

MSW that “should” mock but does not is usually a path mismatch (trailing slash wars, different base URL) or the worker not actually starting once per lifecycle. I log the requests I care about and confirm one startup pattern—double worker.start() is how you get “works on my machine” CI.

Slow PR checks are either too many CT tests (move low-value cases back to Vitest) or global beforeMount doing a conference keynote before every spec. I move rare setup next to the two tests that need it and shard only when the suite is actually large.

“Works in Storybook, fails in CT” is different Vite graphs or import order in the CT entry. I diff the configs like a code review, not a hunch. That sounds boring; it is faster than another Slack thread.

Coverage nerds, one sentence: the CT browser bundle is not the Node test bundle, and smashing Istanbul from CT together with Vitest in one pretty HTML report is version-sensitive and a frequent upgrade casualty. I let Vitest own line coverage for logic and use CT for the UI behavior I actually distrust. If you need one merged number for compliance, plan a dedicated CI job and own it when it explodes on the next major.

Real project structure (monorepo-friendly)

A layout that scales in design system and app repos:

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/
      ...

Patterns that work in production:

  • packages/ui runs CT + Storybook; the app consumes the package. CT guards low-level visuals; the app’s E2E still validates routing and real APIs.
  • Shared MSW handlers.ts is imported from Storybook preview and from playwright/index, so the same network contract is exercised everywhere.
  • Colocated *.ct.spec files next to components keep ownership and PR scope small.

E2E vs CT: drawing the boundary (again, as an operational rule)

  • CT: “Given controlled props/providers, does this component look and act correctly in a real browser?”
  • E2E: “Can a user achieve the journey with real routing, auth, and services?”

Use CT to stop design system regressions early; use E2E to stop product regressions. The overlap in confidence is not 100%—that is the point of layering.

References

  • [Cypress E2E testing — selectors, cy.intercept](/en/blog/cypress-e2e-testing-guide/
  • [Vitest browser mode — real browser tests](/en/blog/vitest-browser-mode-testing-guide/
  • [Playwright E2E testing — automation, locators](/en/blog/playwright-e2e-testing-guide/

Keywords for search: Playwright, component testing, E2E, visual testing, React, Vue, Svelte, MSW, Vite, GitHub Actions.