Skip to content

Fix: Playwright Component Testing Not Working — Mount Fixture, Vite Config, Styles, and TypeScript

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix Playwright component testing errors — playwright-ct.config not found, mount fixture undefined, CSS not loaded in tests, Vite alias for imports, TypeScript paths, hooks (beforeMount), and snapshot strategy.

The Error

You install Playwright CT and the config isn’t found:

$ npx playwright test -c playwright-ct.config.ts
# Error: No tests found. Did you mean to run them in a different mode?

Or mount is undefined:

test("Button renders", async ({ mount }) => {
  await mount(<Button>Click</Button>);
  // TypeError: mount is not a function
});

Or styles don’t load in the test snapshot:

test("themed button", async ({ mount }) => {
  const button = await mount(<Button variant="primary" />);
  await expect(button).toHaveScreenshot();  // No background color in snapshot
});

Or TypeScript path aliases don’t resolve:

import { Button } from "@/components/Button";
// Cannot find module '@/components/Button' or its corresponding type declarations.

Why This Happens

Playwright Component Testing is a separate test runner from regular Playwright. It mounts components in a real browser via a small Vite server, then drives them with the standard Playwright API.

  • Two configs. Regular playwright.config.ts is for E2E (full browser navigations). Component testing needs playwright-ct.config.ts (or similarly named). They use different fixtures and different file globs.
  • Framework adapter is required. @playwright/experimental-ct-react, -vue, -svelte, -solid — pick the one that matches your framework. They provide the mount fixture.
  • Vite serves your components. Your project’s CSS, aliases, and plugins need to be replicated in the CT Vite config — otherwise styles and resolution differ from your real app.
  • TypeScript paths in tsconfig.json aren’t auto-honored by Vite; you need a Vite plugin (vite-tsconfig-paths) or duplicate them in vite.config.ts.

A second layer of failure comes from the package name. Every component-testing entry point still ships under @playwright/experimental-ct-*. That experimental- prefix is a deliberate Playwright signal: the API can change between releases, sometimes in non-obvious ways. The 1.30 → 1.40 transition added new fixture properties, 1.45 expanded Solid and Svelte 5 support, and several minor releases altered how ctViteConfig merges with your project’s vite.config.ts. If you upgraded Playwright but kept the same playwright-ct.config.ts from a 2023 tutorial, fields that used to be valid may now log warnings, and mount behaviour may differ. Always check the Playwright release notes for the exact version installed (npx playwright --version).

A third common cause is dependency mismatch between Playwright core and the CT adapter. @playwright/test and @playwright/experimental-ct-react must be on the same minor version. If package.json pins @playwright/test to ^1.45 but the CT adapter is at 1.40, the mount fixture’s TypeScript signature can drift from what the runtime provides, producing the “mount is not a function” error at runtime even though the import compiles. Always upgrade them together: npm install @playwright/test@latest @playwright/experimental-ct-react@latest.

Fix 1: Install and Configure

For React:

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

Then:

// playwright-ct.config.ts
import { defineConfig, devices } from "@playwright/experimental-ct-react";

export default defineConfig({
  testDir: "./",
  testMatch: /.*\.spec\.tsx?$/,
  snapshotDir: "./__snapshots__",
  timeout: 10_000,
  fullyParallel: true,
  workers: process.env.CI ? 1 : undefined,
  reporter: "html",
  use: {
    trace: "on-first-retry",
    ctPort: 3100,
    ctViteConfig: {
      // Vite config — see Fix 3
    },
  },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
  ],
});

Run:

npx playwright test -c playwright-ct.config.ts

For Vue:

npm install -D @playwright/experimental-ct-vue
import { defineConfig } from "@playwright/experimental-ct-vue";
// Same shape, different framework.

For Svelte / Solid: same pattern with their respective packages.

Pro Tip: Use a separate package.json script:

{
  "scripts": {
    "test:ct": "playwright test -c playwright-ct.config.ts",
    "test:e2e": "playwright test -c playwright.config.ts"
  }
}

Avoids confusion about which config runs.

Fix 2: Write a Component Test

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

test("renders children", async ({ mount }) => {
  const component = await mount(<Button>Click me</Button>);
  await expect(component).toContainText("Click me");
});

test("fires onClick", async ({ mount }) => {
  let clicks = 0;
  const component = await mount(
    <Button onClick={() => clicks++}>Click</Button>
  );
  await component.click();
  expect(clicks).toBe(1);
});

test("primary variant", async ({ mount }) => {
  const component = await mount(<Button variant="primary">Save</Button>);
  await expect(component).toHaveCSS("background-color", "rgb(0, 122, 255)");
});

The mount fixture returns a Locator rooted at your component. All Playwright assertions and actions work on it.

Common Mistake: Importing test from @playwright/test instead of @playwright/experimental-ct-react. The latter has the mount fixture; the former doesn’t.

Fix 3: Configure Vite for Your Project

CT uses Vite to bundle your components. To match your real app’s behavior:

// playwright-ct.config.ts
import { defineConfig } from "@playwright/experimental-ct-react";
import path from "path";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  // ...
  use: {
    ctPort: 3100,
    ctViteConfig: {
      resolve: {
        alias: {
          "@": path.resolve(__dirname, "./src"),
        },
      },
      plugins: [tsconfigPaths()],
      css: {
        postcss: "./postcss.config.cjs",  // For Tailwind / PostCSS
      },
    },
  },
});

Three things to replicate from your vite.config.ts:

  • Aliases — for @/components and similar.
  • PostCSS / Tailwind config — so styles process correctly.
  • Plugins — anything you depend on at build time (mdx, svgr, etc.).

If you have a complex vite.config.ts, import and reuse:

import sharedConfig from "./vite.config";

export default defineConfig({
  use: {
    ctViteConfig: { ...sharedConfig, /* CT-specific overrides */ },
  },
});

Fix 4: Load Global Styles

Components that depend on global CSS need to import it in the test entry point:

// playwright/index.ts (CT's setup file)
import "../src/styles/globals.css";   // Tailwind, fonts, resets
import "../src/styles/tokens.css";

Configure the entry in playwright-ct.config.ts:

export default defineConfig({
  // ...
  use: {
    ctPort: 3100,
    ctTemplateDir: "./playwright",   // Directory with index.html and index.ts
  },
});

CT looks for playwright/index.html and playwright/index.ts (or index.tsx) by default. The index.ts is your setup; the index.html is the page template (usually a minimal <div id="root"></div>).

For Tailwind:

<!-- playwright/index.html -->
<!DOCTYPE html>
<html>
  <head><title>CT</title></head>
  <body>
    <div id="root"></div>
    <script type="module" src="./index.ts"></script>
  </body>
</html>
// playwright/index.ts
import "../src/styles/globals.css";  // Tailwind directives + your CSS

Tailwind’s content config must include the test files:

// tailwind.config.js
module.exports = {
  content: [
    "./src/**/*.{ts,tsx}",
    "./**/*.spec.tsx",   // CT test files
  ],
};

Without the spec files in content, Tailwind purges classes that only appear in tests.

Fix 5: Hooks — beforeMount and afterMount

For provider context (theme, query client, router), wrap your mounts:

// playwright/index.ts
import { beforeMount, afterMount } from "@playwright/experimental-ct-react/hooks";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";

beforeMount(async ({ App, hooksConfig }) => {
  const queryClient = new QueryClient();
  return (
    <QueryClientProvider client={queryClient}>
      <MemoryRouter initialEntries={hooksConfig?.routes ?? ["/"]}>
        <App />
      </MemoryRouter>
    </QueryClientProvider>
  );
});

In your test, pass hooksConfig:

test("home route", async ({ mount }) => {
  const component = await mount(<HomePage />, {
    hooksConfig: { routes: ["/home"] },
  });
});

beforeMount wraps every component with shared providers. afterMount runs after mount (e.g. for cleanup or DOM inspection).

Common Mistake: Trying to render multiple components in beforeMount. The function receives the test’s component as <App /> and must return a tree with <App /> at some point.

Fix 6: Locators and Interactions

Once mounted, use standard Playwright locators:

test("filter list", async ({ mount }) => {
  const component = await mount(<UserList />);
  
  await component.getByPlaceholder("Search").fill("alice");
  await component.getByRole("button", { name: "Search" }).click();
  
  await expect(component.getByText("Alice")).toBeVisible();
  await expect(component.getByText("Bob")).not.toBeVisible();
});

For shadow DOM:

await component.locator("custom-element").locator(":scope >> .inner").click();

For attached elements outside the component root (portals, tooltips):

// component is the root mounted element.
// For portals that render to document.body:
const tooltip = component.page().getByRole("tooltip");
await expect(tooltip).toBeVisible();

component.page() returns the full page Playwright object, useful for portals, modals, and any DOM outside the component subtree.

Fix 7: Snapshot Testing

For visual regression:

test("button screenshot", async ({ mount }) => {
  const component = await mount(<Button>Click</Button>);
  await expect(component).toHaveScreenshot("button.png");
});

The first run creates button.png next to the test file (or under __snapshots__). Subsequent runs compare.

To update snapshots:

npx playwright test -c playwright-ct.config.ts --update-snapshots

For DOM snapshot:

test("button DOM", async ({ mount }) => {
  const component = await mount(<Button>Click</Button>);
  expect(await component.innerHTML()).toMatchSnapshot("button.html");
});

Pro Tip: Visual snapshots are sensitive to fonts, antialiasing, and animations. Disable animations for stable snapshots:

/* In playwright/global.css */
*, *::before, *::after {
  animation-duration: 0s !important;
  transition-duration: 0s !important;
}

Import in playwright/index.ts.

Fix 8: TypeScript Setup

// tsconfig.json
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "moduleResolution": "bundler",
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*", "**/*.spec.tsx", "playwright/**/*"]
}

For per-test-type tsconfig:

// tsconfig.test.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "types": ["@playwright/experimental-ct-react"]
  },
  "include": ["**/*.spec.tsx", "playwright/**/*"]
}

The types array adds Playwright CT’s ambient declarations, so editor autocomplete works for mount, hooksConfig, etc.

Common Mistake: Mixing CT’s test import with regular Playwright’s. The CT one has extra fixtures. ESLint can’t catch this — verify your imports.

Version History: Playwright CT From 1.30 to 1.45 and Where It’s Going

Playwright Component Testing has been “experimental” since it shipped — and that label is honest. Knowing which release introduced which feature is the difference between a working CT setup and three hours debugging “why does my Vite alias work in dev but not in tests.”

Playwright 1.30 (January 2023) — initial CT release. React and Vue 3 only. The mount fixture was minimal: no hooksConfig, no beforeMount/afterMount. You wrapped providers manually by passing JSX from the test. ctViteConfig accepted only a small subset of Vite options and silently ignored unknown fields. Snapshot tests worked but the diff renderer was crude.

Playwright 1.31–1.34 (Spring 2023) — Svelte 3 support. @playwright/experimental-ct-svelte arrived. Tests for Svelte components used .svelte files mounted similarly. Vue 3’s adapter was rewritten to use app.mount directly, fixing a class of “missing root element” errors.

Playwright 1.35–1.39 (Summer-Autumn 2023) — hooksConfig and providers. beforeMount / afterMount hooks (Fix 5) landed. The hooksConfig object on mount(component, { hooksConfig }) made it possible to vary provider props per test without rewriting the whole hook. This is the version that made CT actually useful for real apps that depend on React Query, React Router, or Vue Router.

Playwright 1.40 (November 2023) — Solid support, stable JSX-in-CT. @playwright/experimental-ct-solid joined the lineup. The mount fixture’s return type became a proper Locator across all frameworks, so chaining .locator() and .getByRole() on the mounted component is consistent.

Playwright 1.41–1.44 (early-mid 2024) — Vite 5, Vue SFC props, snapshot diff. CT’s bundled Vite was updated to v5. Vue SFC <script setup> props parsing improved. The screenshot diff UI got the new pixel-by-pixel renderer that’s now standard across Playwright.

Playwright 1.45 (July 2024) — Svelte 5 + framework-config flexibility. Svelte 5’s runes-based components are supported. ctViteConfig accepts a function that returns a Vite config, which makes it easier to share logic with your app’s vite.config.ts.

Later releases. Subsequent minor versions have continued the same incremental track: more framework versions supported, better TypeScript inference on the mount fixture, faster startup. The experimental- prefix remains because the team has signalled at least one more API revision before 1.0-ing the feature.

vs Storybook test-runner

Storybook’s test-runner uses Jest + Playwright to drive existing .stories.tsx files in a real browser. Compared to Playwright CT:

  • Storybook’s story files double as both interactive docs and tests. CT’s .spec.tsx files are test-only.
  • Storybook test-runner is slower to boot (it loads the full Storybook UI in a hidden frame). CT’s Vite server is leaner.
  • Storybook works without writing tests — you get visual regression “for free” from existing stories via the Chromatic add-on. CT requires explicit test code.
  • Use Storybook test-runner if you already have Storybook for docs and want tests on top. Use Playwright CT if you want tests directly and don’t want a Storybook dependency.

vs Cypress Component Testing

Cypress also offers component testing. The differences:

  • Cypress CT uses Cypress’s bundled browser harness with its own iframe model. Playwright CT uses Vite’s dev server and a near-stock Chromium.
  • Cypress’s cy.mount() is more mature — it shipped earlier (2022) and has fewer breaking changes per release.
  • Playwright’s parallelism is significantly better. CT runs tests across files in parallel by default; Cypress component tests parallelise per-spec only.
  • Pick Cypress CT if your team already runs Cypress E2E. Pick Playwright CT for a smaller installed footprint and faster CI.

Pinning strategy

Pin both @playwright/test and the framework adapter to the same exact version (not ^) in production projects:

{
  "devDependencies": {
    "@playwright/test": "1.45.0",
    "@playwright/experimental-ct-react": "1.45.0"
  }
}

Upgrade with npm install -E @playwright/test@latest @playwright/experimental-ct-react@latest simultaneously. Misaligned minors are the #1 cause of “worked yesterday, broken today” CT failures.

Still Not Working?

A few less-obvious failures:

  • Cannot find module 'vite-tsconfig-paths'. Install it: npm install -D vite-tsconfig-paths. The fallback is duplicating paths in ctViteConfig.resolve.alias.
  • Tests pass locally, fail in CI. Workers count mismatch (CI may have less memory). Set workers: process.env.CI ? 1 : undefined and fullyParallel: false for stability.
  • Slow test startup. Vite is doing a fresh build on each run. Cache the Vite build artifacts between CI runs.
  • SyntaxError: Cannot use import statement outside a module. Your component imports a CJS-only package. Mark it as optimizeDeps.include in ctViteConfig.
  • State leaks between tests. Component is mounted in a single page; some state (localStorage, cookies) persists. Clear in beforeEach: await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); }).
  • Hot reload doesn’t trigger. CT doesn’t watch by default. Use --ui mode for interactive debugging: npx playwright test -c playwright-ct.config.ts --ui.
  • Wrong viewport size. Playwright CT mounts at default viewport. Set per-project: use: { viewport: { width: 1280, height: 720 } }.
  • document.body is empty in afterMount. Some frameworks (Solid) mount after mount returns. Use await page.waitForSelector("[data-mounted]") with a marker.
  • TypeScript types for mount regress after upgrade. When the CT adapter ships a new minor with refined fixture types, code that compiled cleanly may now flag a type error on mount(<Foo prop={undefined} />). Update your component prop types — usually the CT type is now stricter (and correct).
  • Mixing two CT adapters in a monorepo. If one package depends on @playwright/experimental-ct-react and another on @playwright/experimental-ct-vue, hoisting can install duplicate Playwright cores. Pin both to the same version and use your package manager’s resolutions/overrides.
  • page.route() calls don’t intercept CT-mounted requests. CT’s mount runs after the page has loaded the harness HTML, so routes you set up after mount may miss the initial component fetch. Set up page.route() before mount or use the beforeEach hook.

For related testing and Playwright issues, see Playwright not working, Vitest setup not working, Jest mock not working, and React testing library not finding element.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles