A minimal, production-inspired example of a Playwright Component Testing setup that supports two run modes:
| Mode | Command | When to use |
|---|---|---|
| Dev | pnpm test:dev |
Local development using Vite's native ESM dev server |
| Preview | pnpm test:preview |
CI or full validation using Playwright's built-in Vite bundler (production build) |
Context — This repository was created in response to microsoft/playwright#14748, which requests a Vite dev server mode for component tests. Playwright's team deliberately uses a production build to keep CI tests fast, but the cold-start cost of that build can be painful during local development on large projects. This setup offers both options without duplicating test code.
- Key difference from standard Playwright CT
- Architecture
- Getting started
- CLI usage
- Browser usage
- VS Code usage
- Debugging
- How it works
- Adding components
- Extending providers
Two differences from standard Playwright CT:
- Story pattern required.
Components must be wrapped in a thin story component exported from a dedicated story file (e.g. counter.story.tsx).
Tests mount the story, not the component directly.
See the Story pattern section.
hooksConfig.storyFileNamerequired in dev mode.
Every mount() call must pass the story's file name through hooksConfig:
await mount(<CounterStory />, {
hooksConfig: { storyFileName: "./counter.story.tsx" },
});See hooksConfig.storyFileName for the full explanation.
playwright-ct-dev-server/
├── app/ # Example Vite + React + TypeScript app
│ ├── src/
│ │ ├── counter.tsx # Example component
│ │ └── stories/
│ │ └── counter.story.tsx # Story file — thin wrappers around the component
│ └── vite.config.ts
│
└── e2e/ # Playwright component tests
├── ct-dev-server/ # Dev server integration
│ ├── index.html # Entry point served by the Vite dev server
│ ├── entry.ts # Dynamically imports the story and renders it
│ ├── serializers.ts # Serialize/deserialize function props across the Node↔Browser boundary
│ └── vite-config-ct-dev.ts # Vite config for the CT dev server (port 3200)
│
├── ct-preview-server/ # Preview server integration
│ └── vite-config-ct-preview.ts # Vite config used by Playwright CT's built-in bundler (port 3100)
│
├── playwright/ # Playwright CT template (shared by both modes)
│ ├── index.html # Standard Playwright CT HTML template
│ ├── index.tsx # beforeMount hook — add providers here
│ └── types.ts # HooksConfig type
│
├── configs/
│ ├── base/playwright.config.base.ts # Shared Playwright config (CT settings, reporters, timeouts)
│ ├── dev/playwright.config.ts # Dev config — disables CT bundler, starts CT dev server
│ └── preview/playwright.config.ts # Preview config — enables CT bundler
│
├── fixtures/
│ └── ct-dev-mount.ts # Custom mount fixture — the glue between modes
├── utils/
│ └── paths.ts # Absolute path helpers (repositoryRoot, e2eRoot, appRoot)
└── tests/
├── test-component.ts # Re-exports the test object with fixtures merged
└── components/
└── counter.spec.tsx # Example component test
| Service | Port | Config |
|---|---|---|
App dev server (pnpm app:dev) |
5173 | app/vite.config.ts |
App preview server (pnpm app:preview) |
4173 | app/vite.config.ts |
CT dev server (pnpm ct:dev-server) |
3200 | e2e/ct-dev-server/vite-config-ct-dev.ts |
| Playwright CT built-in server (preview mode) | 3100 | e2e/ct-preview-server/vite-config-ct-preview.ts |
Prerequisites: Node.js ≥ 20 and pnpm ≥ 9.
# Install dependencies
pnpm install
# Install Playwright browsers
pnpm -F e2e playwright:installpnpm test:devPlaywright automatically starts the CT dev server before the first test run.
Changes to the app or stories are picked up immediately, no rebuild required.
Tip: Pre-start the server manually to avoid the startup delay on the very first run:
# Terminal 1 pnpm ct:dev-server # Terminal 2 pnpm test:dev
pnpm test:previewPlaywright starts its own Vite server automatically, builds the app, runs all tests, then shuts it down.
# Dev mode
pnpm test:dev -- tests/components/counter.spec.tsx
# Preview mode
pnpm test:preview -- tests/components/counter.spec.tsxpnpm reportWhile the CT dev server is running (pnpm ct:dev-server), any story can be opened directly in a browser without writing a test. The URL format is:
http://localhost:3200/ct-dev-server/?story=<StoryFile>&name=<ExportedName>
| Parameter | Description | Example |
|---|---|---|
story |
Basename of the story file (resolved by the ct-story-resolver) |
counter.story.tsx |
name |
Exact name of the exported function in that file | CounterStory |
props |
(optional) JSON-encoded props to pass to the story | {"initialCount":5} |
Examples:
# Default counter
http://localhost:3200/ct-dev-server/?story=counter.story.tsx&name=CounterStory
# Counter pre-set to 5
http://localhost:3200/ct-dev-server/?story=counter.story.tsx&name=CounterWithInitialValueStory&props={"initialCount":5}
This is useful for rapid visual iteration: edit the component or story, save, and the browser hot-reloads instantly — no test run required.
Install the Playwright Test for VSCode extension by Microsoft.
The Playwright extension auto-discovers playwright.config.ts files in the workspace. Both configs appear in the Testing panel:
- Open the Testing panel (
Ctrl+Shift+P→ "Testing: Focus on Playwright View"). - Unfold the "Playwright" section.
- In the "Configs" section, two test suites are listed:
e2e/configs/dev/playwright.config.tse2e/configs/preview/playwright.config.ts
- Select the desired config using the "Toggle Playwright Configs" cog icon.
- Set a breakpoint in your test file (
.spec.tsx). - Click the Debug icon next to the test in the Testing panel.
- The browser opens in headed mode; execution pauses at the breakpoint.
The CT dev server serves original TypeScript source files with inline source maps, so Chrome DevTools can display and debug them directly.
- Start the CT dev server:
pnpm ct:dev-server - Open any story in Chrome:
http://localhost:3200/ct-dev-server/?story=counter.story.tsx&name=CounterStory - Open DevTools (
F12) in Sources panel. - Find the app files under the
/@fs/virtual path (e.g./@fs/C:/…/app/src/counter.tsx). - Click a line number to set a breakpoint, then interact with the component.
This mode lets you set breakpoints directly in VS Code and step through the app source code without touching DevTools.
Steps:
- Start the CT dev server:
pnpm ct:dev-server - Open any app source file (e.g.
app/src/counter.tsx) and click the gutter to add a breakpoint. - Open the Run and Debug panel (
Ctrl+Shift+D). - Select Debug CT Dev Server (Chrome) and press
F5. - Chrome opens a story URL, navigate to the story you want to debug.
- VS Code pauses at the breakpoint when the code is hit.
This is the most powerful option: VS Code breakpoints in app source files that fire while a Playwright test is running in dev mode.
Preview mode not supported. Playwright CT's preview server does a Vite production build and serves the output as static bundles. The
.mapfiles produced by the build are not served by that static server, so Chrome cannot load the source maps and VS Code breakpoints remain unbound. Use dev mode for this workflow.
How it works:
PW_REMOTE_DEBUGGING_PORT=9222launches Chromium with--remote-debugging-port=9222, exposing a CDP endpoint that VS Code can attach to.- Before navigating to the story, the
mountfixture automatically pauses for 1.5 s. VS Code attaches to the CDP endpoint during this window and registers all breakpoints. - The fixture then navigates Playwright's Chromium to
http://localhost:3200/ct-dev-server/?story=…where the app code runs as native Vite ESM modules with full source maps.
Steps from CLI:
- Start the CT dev server:
pnpm ct:dev-server - Set a breakpoint in an app source file (e.g.
app/src/counter.tsx). - Open the Run and Debug panel (
Ctrl+Shift+D) and start Attach to Playwright browser — it will retry connecting until Chromium is ready (up to 60 s, as configured in.vscode/launch.json). - Run the tests with the env var and a single worker:
On PowerShell:
PW_REMOTE_DEBUGGING_PORT=9222 pnpm test:dev --workers=1
Example of running a specific test within a test file:$env:PW_REMOTE_DEBUGGING_PORT="9222"; pnpm test:dev -- --workers=1
PW_REMOTE_DEBUGGING_PORT=9222 pnpm test:dev --workers=1 "tests/components/counter.spec.tsx" -g "increments the count on click"
- VS Code pauses at the breakpoint when the test triggers the code path.
Steps from the VS Code Testing panel:
Uncomment the entry in .vscode/settings.json:
- Start the CT dev server:
pnpm ct:dev-server - Set a breakpoint in an app source file (e.g.
app/src/counter.tsx). - Check Show browser option in the Playwright sidebar.
- Open the Run and Debug panel (
Ctrl+Shift+D) and start Attach to Playwright browser. - Run the test.
Playwright CT's default flow for every test run:
- Full Vite production build of the app
- Start a static server on the built output
- Run the tests
This is great for CI: build once, run many tests fast. But locally, re-building after every single-line change kills the feedback loop.
Dev mode Preview mode
───────────────────── ──────────────────────────
Test process → ct-dev-mount.ts mount() from @pw/ct-react
↓ ↓
Browser → CT dev server (port 3200) PW CT bundled server (3100)
↓ ↓
Vite → Native ESM dev mode Production build
Dev mode replaces Playwright CT's built-in mount fixture with a custom one:
- Disables the bundler:
ctPort,ctTemplateDir,ctViteConfigare set toundefinedin the dev config. - Fakes the base URL: Before Playwright CT can navigate to its own server, the fixture sets
PLAYWRIGHT_TEST_BASE_URLto a fake URL and intercepts requests to it, returning an empty HTML page. This keeps Playwright CT happy without actually loading anything. - Navigates to the CT dev server: The custom
mountcall encodes the story file name, component name, and props as URL query parameters and navigates tohttp://localhost:3200/ct-dev-server/?.... - Deserializes function props: Function-typed props cannot be JSON-serialized.
serializers.tsreplaces them with ordinal references ({ __pw_type: "function", ordinal: 0 }). A bridge function (__ctDevDispatchFunction) is exposed on the page viapage.exposeFunction(), forwarding browser calls back to the Node.js callbacks in the test. - Renders the story:
entry.tsrunning in the browser reads the query params, dynamically imports the story module (via thect-story-resolverVite plugin), and calls the sharedbeforeMountHookto render the component with the same providers as preview mode.
Both modes share:
playwright/index.tsx: thebeforeMounthook and its providersplaywright/types.ts: theHooksConfigtype- The test files themselves : no changes needed to run in either mode except passing the
storyFileNamein dev mode (see below).
Stories are thin wrapper components that live next to the app code:
// app/src/stories/counter.story.tsx
export function CounterWithCallbackStory({ onCountChange }) {
return <Counter onCountChange={onCountChange} />;
}Tests import the story components directly and mount them:
// e2e/tests/components/counter.spec.tsx
await mount<HooksConfig>(<CounterWithCallbackStory onCountChange={fn} />, {
hooksConfig: { storyFileName: "./counter.story.tsx" },
});The storyFileName is the only extra piece of information needed in dev mode, it tells the CT dev server which story file to import.
| Mode | storyFileName |
Why |
|---|---|---|
| Dev | Required | The dev server receives only a URL with query parameters. It has no static analysis context and must dynamically import() the right story file at runtime, the only way to identify it is via this parameter. Omitting it throws an error immediately. |
| Preview | Ignored | Playwright CT's bundler statically analyses all import statements in the test files at build time. It already knows every component to include in the bundle. The parameter is passed through hooksConfig but never read. |
Because both modes run the same test files, the pattern is to always pass storyFileName.
Tip: Use
test.extendto injectstoryFileNameonce per test file instead of repeating it in everymountcall. See the Adding components section for the pattern.
- Create the component in
app/src/. - Create a story file in
app/src/stories/exporting one or more story components. - Create a test file in
e2e/tests/components/usingtest-component.ts.
// app/src/stories/my-component.story.tsx
import { MyComponent } from "../my-component";
export function MyComponentStory() {
return <MyComponent />;
}// e2e/tests/components/my-component.spec.tsx
import { MyComponentStory } from "../../app/src/stories/my-component.story";
import type { HooksConfig } from "../../playwright/types";
import { test, expect } from "../test-component";
// Extend the base test with a custom mount that injects the storyFileName into hooksConfig:
const testWithStory = test.extend({
mount: async ({ mount }, use) => {
await use((component, options) =>
mount<HooksConfig>(component, {
...options,
hooksConfig: {
...options?.hooksConfig,
storyFileName: "./my-component.story.tsx",
},
}),
);
},
});
testWithStory("renders correctly", async ({ page, mount }) => {
await mount(<MyComponentStory />);
await expect(page.getByRole("…")).toBeVisible();
});
// Or pass hooksConfig directly in the test body:
testWithStory("renders correctly", async ({ page, mount }) => {
await mount(<MyComponentStory />, {
hooksConfig: { storyFileName: "./my-component.story.tsx" },
});
await expect(page.getByRole("…")).toBeVisible();
});Add providers (React Query, theme, internationalization, …) to e2e/playwright/index.tsx:
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeMount } from "@playwright/experimental-ct-react/hooks";
import type { HooksConfig } from "./types";
export async function beforeMountHook({ App, hooksConfig }) {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
}
beforeMount<HooksConfig>(beforeMountHook);The hook is called in both dev and preview mode, so providers only need to be defined once.