Component testing with Playwright: story and gallery concept
Component testing with Playwright uses a story gallery approach. A story is a tiny wrapper component that embeds the component under test in one specific scenario, with hard-coded props, mock data, providers, and recorded callbacks. Stories live next to the component in *.story.tsx (or .ts/.jsx/.js/.vue) files with each named export as one story. The gallery is a single page that implements window.mount(params) and window.unmount() to render a story into #root. Tests are plain Playwright tests using the built-in mount(storyId, props?) fixture from @playwright/test.
Component testing setup: framework detection
Component testing setup begins by detecting the framework (React vs Vue) and bundler. If the app runs on Vite (has vite.config.*), the gallery is served by the existing dev server at /playwright/gallery/index.html — Vite serves any .html file under the project root and vite build ignores it, with no extra server needed. For anything else (Next.js, webpack, or no dev server), run a small standalone dev server like Vite that serves the gallery page and point baseURL at it, requiring vite and the framework plugin as devDependencies.
Component testing Playwright configuration
For component testing, add to playwright.config.ts: a project named 'components' with testDir './tests/components' and use config including ...devices['Desktop Chrome'], baseURL: 'http://localhost:5173/playwright/gallery/index.html', serviceWorkers: 'block', and reuseContext: true. webServer config should have command 'npm run dev' (or 'npx vite --config playwright/vite.config.ts'), url 'http://localhost:5173/playwright/gallery/index.html' (or for standalone server: http://localhost:3100/playwright/gallery/index.html), and reuseExistingServer: !process.env.CI. Match the port to the dev server. serviceWorkers: 'block' keeps the app's service worker from serving cached responses shadowing page.route() mocks. reuseContext: true reuses the browser context across tests in a worker for speed.
Component testing story ID naming convention
Story ID follows the pattern: path under src/ without the .story.* extension, plus the export name. For example, src/components/Button.story.tsx export Primary becomes components/Button/Primary. Any unique suffix works too; mount('Button/Primary') is valid. A .story.vue single-file component is one story addressable by its path alone via its default export.
Component testing: callbacks and state recording pattern
In component testing, the story owns the state and provides callbacks. Where the component takes callbacks, create state inside the story, wire callbacks to it, and record the state into a hidden form next to the component. Tests perform operations and assert on recorded values. Each observed value goes into its own data-testid input (using String(...) or JSON.stringify(...) for payloads) and assert with toHaveValue() — a web-first assertion that retries until state lands.
Component testing: callbacks and events example
Example of state recording in a React story:
export const Stateful = () => {
const [expanded, setExpanded] = useState(false);
return <>
<Expandable expanded={expanded} setExpanded={setExpanded} title="Title">Details</Expandable>
<form hidden><input data-testid="expanded" readOnly value={String(expanded)} /></form>
</>;
};
Test example:
test('click should expand', async ({ mount }) => {
const component = await mount('components/Expandable/Stateful');
await component.locator('.codicon-chevron-right').click();
await expect(component.getByTestId('expanded')).toHaveValue('true');
});
Component testing: per-test props with mount
When a scenario is genuinely parametric, pass props as the second argument to mount; the gallery hands them to the story as its props. Keep props to plain serializable data — callbacks belong inside the story. Example: const component = await mount('components/Button/WithTitle', { title: 'Hello' }); mount is generic over the story; pass the story type as a template argument to type-check props: const component = await mount<typeof WithTitle>('components/Button/WithTitle', { title: 'Hello' });
Component testing: prop transitions with update()
To test how a component reacts to a prop change without remounting (state preserved), call component.update(newProps) — it re-renders the same story with new props on the existing root. This requires the gallery to reuse its root/instance; state survives as long as the story stays the same. Example:
const component = await mount('components/Counter/Default', { value: 1 });
await expect(component.getByTestId('value')).toHaveText('1');
await component.update({ value: 2 });
await expect(component.getByTestId('value')).toHaveText('2');
Component testing: multiple stories in one test
Each mount() navigates fresh, so tests are fully isolated and mounting several stories in one test is cheap. For visual comparison, screenshot the returned root locator, not the page, to avoid asserting on browser chrome. Example:
await expect(await mount('Button/Primary')).toHaveScreenshot('primary.png');
await expect(await mount('Button/Disabled')).toHaveScreenshot('disabled.png');
Component testing: network mocking with page.route()
Use page.route() for network mocking in component tests as usual — register routes before mount(), since mounting navigates. serviceWorkers: 'block' (set in the config) keeps the app's own service worker from serving cached responses that shadow the routes. Teams with MSW handler libraries can start the worker inside a story or decorator instead.
Component testing: story implementation requirements
Everything the component needs must be set up inside the story (it runs in the browser); everything the test asserts must be observable through the page (DOM, URL, network). Where the component takes callbacks, the story creates the state, provides the callbacks and records the state into a hidden form for the test to assert on. mount(id, props) passes plain serializable props to the story.
Component testing: gallery implementation scope
The gallery is framework-specific and owned by the implementer. Keep story discovery (import.meta.glob) and the framework mount in the gallery — this is the only framework-specific glue, so keep it small. Import the app's global CSS the same way the app's own entry does. The gallery is a page at <project>/playwright/gallery/ that renders the requested story into #root, starting from the worked example in gallery-spec.md and framework notes in references/react.md or references/vue.md.
Gallery contract: window.mount and window.unmount methods
The gallery exposes two methods on window for Playwright component testing to drive: window.mount(params) renders a story and window.unmount() unmounts the current story. The gallery is a single page served by your dev server at the baseURL set in the Playwright config.
window.mount(params) signature and behavior
window.mount receives params as { story, props }. The story parameter is a story id string that must be resolved to a component. The props parameter is a plain serializable props object. The function must render the resolved component with props into #root and return a Promise that resolves once mounted. The Promise must reject on failure, such as unknown story or render throw.
Component testing update() preserves state through root reuse
The window.mount function must reuse the root element across calls. When component.update(props) calls window.mount again with the same story and new props without navigating, rendering into the same root instance instead of recreating it allows the framework to reconcile and preserve component-internal state. Recreating the root or navigating resets state.
window.mount as setup/teardown hook for component testing
window.mount serves as the browser-side equivalent of CT's beforeMount and afterMount hooks. It is the one function a test owns; there is no separate hook registry. Inside window.mount, you can install providers or plugins, seed a store, start an in-browser mock server before rendering, and run post-render work after rendering—all branched on the story and props the test passed.
window.unmount() for component testing teardown
window.unmount() unmounts the current story from #root and returns a Promise. The test calls it via component.unmount(). It is needed only to assert teardown/cleanup effects; each mount navigates fresh, so tests are already isolated.
Root element #root for component testing
The component must be rendered into an element with id='root'. The mount function returns a Locator for #root itself, so tests scope their queries from there using patterns like component.getByRole('button').click(). Stories are free to render fragments, such as the component plus a hidden form recording its state.
Recommended story id grammar for component testing
The recommended story id scheme is <path under src, without the .story.* extension>/<ExportName>. For example, src/components/Button.story.tsx exporting Primary resolves to components/Button/Primary. Any unique trailing suffix resolves too: Button/Primary. A single-file-component story like Button.story.vue is one story addressed by its path alone: components/Button.
React gallery implementation with root reuse
Example gallery implementation for React + Vite SPA using createRoot and flushSync to preserve state across updates:
```tsx
// playwright/gallery/main.tsx
import { flushSync } from 'react-dom';
import { createRoot, type Root } from 'react-dom/client';
const stories = import.meta.glob('../../src/**/*.story.{tsx,jsx}');
const id = (f: string) => f.replace(/^(\.\.\/)+src\//, '').replace(/\.story\.\w+$/, '');
async function resolve(storyId: string) {
const sep = storyId.lastIndexOf('/');
const [path, name] = [storyId.slice(0, sep), storyId.slice(sep + 1)];
const file = Object.keys(stories).find(f => id(f) === path || id(f).endsWith('/' + path));
const mod = (file && await stories[file]()) as Record<string, any> | undefined;
return mod?.[name] ?? mod?.default;
}
const rootEl = document.getElementById('root')!;
let root: Root | undefined;
(window as any).mount = async ({ story, props }: { story: string, props?: Record<string, any> }) => {
const Story = await resolve(story);
if (!Story)
throw new Error(`Unknown story: ${story}`);
root ??= createRoot(rootEl); // reuse the root so update() reconciles and preserves state
// flushSync so a render error rejects the promise instead of being swallowed.
flushSync(() => root!.render(<Story {...props} />));
};
(window as any).unmount = async () => {
root?.unmount();
root = undefined;
};
```
Pair with index.html containing `<div id="root"></div>` and `<script type="module" src="./main.tsx"></script>`.
Vue gallery implementation with reactive host for state preservation
Example gallery implementation for Vue using a reactive host mounted once with ref updates:
```ts
// playwright/gallery/main.ts
import { createApp, h, shallowRef, type App, type Component } from 'vue';
// resolve() and the import.meta.glob are the same as the React example.
const story = shallowRef<Component | null>(null);
const props = shallowRef<Record<string, any>>({});
const host = { render: () => (story.value ? h(story.value, props.value) : null) };
let app: App | undefined;
(window as any).mount = async ({ story: id, props: next }: { story: string, props?: Record<string, any> }) => {
const resolved = await resolve(id);
if (!resolved)
throw new Error(`Unknown story: ${id}`);
story.value = resolved;
props.value = next ?? {};
if (!app) { // mount once; the ref updates above re-render in place
app = createApp(host);
app.mount('#root');
}
};
(window as any).unmount = async () => {
app?.unmount();
app = undefined;
};
```
Vue's createApp builds a fresh instance each call, so mounting a small reactive host once and updating its refs re-renders in place, preserving state across update() calls.
Component testing gallery must handle plain serializable props
Props passed to mount must be plain serializable data. Where a component takes callbacks, the story must create the state, provide the callbacks, and record the state into a hidden form for the test to assert on.
React component testing gallery file structure
The gallery implementation requires two files: an `index.html` entry point and a `main.tsx` module located in `playwright/gallery/`. The project requires React and React-DOM version 18 or higher to use `createRoot`. Story files follow the glob pattern `src/**/*.story.tsx` (also picks up `.story.jsx` files).
React StrictMode wrapping for component testing
Wrap the rendered story in `<React.StrictMode>` in your gallery to match how most apps render. In development builds, StrictMode intentionally double-invokes render functions and effects. This double-invocation matters for stories that record events with counters set in effects. Recording via state updates from event handlers is unaffected by StrictMode. If a story misbehaves under StrictMode, that is usually a real finding about the component; only drop the wrapper if the app itself does not use StrictMode.
React component testing global providers and decorators
If components require context (theme, store, i18n, router), create one shared decorator and use it in stories so each story states its scenario and nothing more. Keep the decorator in story files rather than building it into the gallery, as this makes the wrapping visible and lets stories opt out.
React component testing global providers decorator example
Example of a shared decorator for global context providers:
```tsx
// src/stories/decorators.tsx
import { ThemeProvider } from '../theme';
import { MemoryRouter } from 'react-router-dom';
export function AppScaffold({ children, route = '/' }: { children: React.ReactNode, route?: string }) {
return (
<ThemeProvider theme="light">
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
</ThemeProvider>
);
}
```
Used in a story file:
```tsx
// src/components/ProfilePage.story.tsx
export const LoggedIn = () => (
<AppScaffold route="/profile/42">
<ProfilePage user={{ id: 42, name: 'Test User' }} />
</AppScaffold>
);
```
React component testing mount generic typing
`mount` is generic over the story: pass the story type as a template argument to type-check per-test props and `update()` calls. Props are inferred from the component signature; both function and class components work.
React component testing mount with typed props example
Example of using typed mount with component props:
Story definition:
```tsx
// src/components/Button.story.tsx
export const WithTitle = ({ title = 'Default' }: { title?: string }) =>
<Button title={title} />;
```
Test usage:
```ts
// src/components/button.spec.ts
import type { WithTitle } from './Button.story';
const component = await mount<typeof WithTitle>('components/Button/WithTitle', { title: 'Hello' });
```
React component testing CSS stylesheets setup
Global stylesheets should be imported in the gallery entry point (`playwright/gallery/main.tsx`), mirroring the app's own entry point. For Tailwind, if content scanning is path-based, ensure `*.story.tsx` files are covered in the configuration.
React component testing data fetching clients
For libraries with client objects (React Query, Apollo), create the client inside the story or decorator so each navigation starts fresh. This prevents state from persisting across story navigations.
Vue gallery setup requires window.mount and window.unmount
The gallery must expose window.mount and window.unmount functions. In Vue, mount using app = createApp(h(story, props)); app.mount('#root') and unmount using app.unmount().
Vue gallery files structure
The gallery is located at playwright/gallery/ and consists of an index.html file plus a main.ts module. Vue 3 is required.
Vue story file naming and location
Stories are located at src/**/*.story.{ts,js,vue}. Render-function stories use .story.ts extension with multiple named exports per file. Single-file-component stories use .story.vue extension with one story per file.
Render-function Vue stories use defineComponent and h()
Render-function stories are written with defineComponent and h(), without SFC compilation. Multiple scenarios can be exported as named exports in a single file. An example is provided at templates/vue/Button.story.ts.
Single-file-component Vue stories use full template syntax
Single-file-component stories use .story.vue extension and support full template syntax including script setup. They are addressed by their path without the extension, for example mount('components/Button.primary'). SFC stories are preferred when scenarios need slots or non-trivial templates.
Single-file-component story example in Vue
A single-file-component story is written as:
```vue
<script setup lang="ts">
import { ref } from 'vue';
import Button from './Button.vue';
const clicks = ref(0);
</script>
<template>
<Button title="Submit" @click="clicks++" />
<form hidden><input data-testid="click-count" readonly :value="String(clicks)" /></form>
</template>
```
This example shows a Button component with click tracking using a ref.
Vue decorator story helper for global plugins
Apps relying on plugins like Pinia, vue-router, or i18n should wrap components with a decorator story helper that creates a fresh instance per story:
```ts
import { defineComponent, h, type Component } from 'vue';
import { createPinia } from 'pinia';
export function withStore(story: Component) {
return defineComponent(() => {
const pinia = createPinia();
return () => h(story, { pinia });
});
}
```
Installing plugins on Vue app instance in gallery
For plugins that must be installed on the app instance using app.use(...), add them in the gallery right after createApp(...) is called. This is equivalent to the app's own bootstrap process.
Vue typed props in render-function stories
A render-function story that takes per-test props must declare them twice: in the setup function signature for typing, and in the props option so Vue delivers them as props rather than attrs:
```ts
export const WithTitle = defineComponent(
(props: { title?: string }) => () => h(Button, { title: props.title ?? 'Default' }),
{ props: ['title'] },
);
```
Vue mount function is generic over story type
The mount function is generic over the story type. Pass the story type as a template argument to type-check the props and update() method:
```ts
import type { WithTitle } from './Button.story';
const component = await mount<typeof WithTitle>('components/Button/WithTitle', { title: 'Hello' });
```
Vue options-API stories infer props automatically
Options-API stories using defineComponent({ props: { ... } }) infer props the same way as render-function stories. For .story.vue SFC stories, prop types are only inferable when the setup generates SFC types using Volar or vue-tsc; otherwise pass the props type directly as a template argument: mount<{ title?: string }>('components/Button.primary', { title: 'Hello' }).
Vue gallery CSS setup
Import global stylesheets in the gallery entry file (playwright/gallery/main.ts), for example import '../../src/assets/main.css'. This mirrors the app's entry point.
Component testing: what it is and how it works
Playwright Test can test components in isolation using a regular end-to-end test that runs against a small story gallery page served by a dev server. There is no dedicated component-testing runtime, no bundler integration, and no extra npm packages. The built-in mount fixture from @playwright/test drives component testing. Tests run in Node.js while components run in a real browser, triggering real clicks and executing real layout. Component tests get all Playwright Test features: parallelism, parametrization, retries, and post-mortem tracing.
Story: small wrapper component for a specific scenario
A story is a tiny wrapper component that embeds the component under test in one specific scenario with hard-coded props, mock data, providers, and recorded callbacks. Stories live next to the component in *.story.tsx (or .ts/.jsx/.js/.vue) files, with each named export representing one story.
Gallery: single page exposing mount and unmount functions
The gallery is a single page served by a dev server that exposes window.mount(params) and window.unmount() functions for rendering a story into a #root element. It is framework-specific and owned by the developer. The gallery resides under playwright/gallery/ and is served by the developer's own dev server.
mount fixture: navigation and component testing
The mount fixture from @playwright/test navigates to the gallery (via baseURL), calls window.mount() with the story id and props, and returns a Locator for the gallery root. Queries should be scoped from the returned component locator.
Gallery setup with init-skills
The fastest way to set up a component testing gallery is using the playwright-component-testing agent skill. Run 'npx playwright init-skills' and ask your coding agent (Claude Code, GitHub Copilot, or similar) to set up. The agent detects the framework and bundler, implements the gallery for the stack, adds a Playwright project to config, and writes the first story and spec.
Playwright config for component testing projects
Add a project to playwright.config.ts for component testing with the following settings: name the project (e.g., 'components'), set testDir to the test directory (e.g., './tests/components'), use a device preset like devices['Desktop Chrome'], set baseURL to the gallery URL (e.g., 'http://localhost:5173/playwright/gallery/index.html'), set serviceWorkers to 'block' to prevent service worker caching, and set reuseContext to true to reuse browser context between tests in a worker for faster execution.
Gallery contract: window.mount and window.unmount functions
The gallery must implement two functions: window.mount({ story, props }) renders the story with given id into a #root element; window.unmount() tears it down. An unknown story or render error causes window.mount to reject, surfacing as the test's mount() call throwing. The gallery reuses the rendering root across calls, so component.update(props) reconciles instead of remounting, preserving component state.
Story id derivation from file path
Story ids are derived from the file path without the .story.* extension, plus the export name. For example, src/components/Button.story.tsx exporting Primary becomes 'components/Button/Primary'. Any unique suffix works as well.
Recording component state for test assertions
Instead of marshalling callbacks between Node.js and browser, stories own the state and provide callbacks, recording observable outcomes into hidden form inputs next to the component. Record each observed value in its own data-testid input using String(...) for scalars and JSON.stringify(...) for payloads. Tests assert using web-first assertions like toHaveValue() that retry until state lands. Keep the form hidden for clean screenshots or drop the hidden attribute while developing to see state live.
Per-test props with mount
Pass plain serializable props as the second argument to mount(). The gallery hands them to the story as its props. Use mount<typeof Story>() as a generic to tie props type-checking to the story signature. Keep props to plain serializable data; callbacks belong inside the story.
Prop transitions with component.update()
Call component.update(newProps) to test how a component reacts to prop changes without remounting. This re-renders the same story with new props on the existing root, preserving component state.
Multiple story screenshots in one test
Each mount() navigates fresh, so tests are fully isolated and mounting several stories in one test is cheap. Screenshot the returned root locator, not the page, to avoid asserting on anything extra in the gallery.
Network request handling in component tests
Use page.route() as usual in component tests. Register routes before mount() since mounting navigates. The serviceWorkers: 'block' option keeps the app's service worker from serving cached responses that would shadow routes. Teams with MSW handler libraries can start the worker inside a story or decorator instead.
Debugging stories in browser
Open the gallery URL in a browser and call await window.mount({ story: 'components/Button/Primary' }) from the DevTools console — this is exactly what the mount fixture does. An unknown story or render error rejects window.mount, surfacing as the test's mount() call throwing with a real stack. Optionally give the gallery an index page listing all discovered stories for browsing without the console.
webServer configuration for component tests
Configure webServer to run the dev server: set command to the dev server start command (e.g., 'npm run dev'), set url to the gallery URL (e.g., 'http://localhost:5173/playwright/gallery/index.html'), and set reuseExistingServer to !process.env.CI to reuse the server outside CI.
Story example: React button with state recording
Example React story recording component state:
```js
import { useState } from 'react';
import { Button } from './Button';
export const CountsClicks = () => {
const [clicks, setClicks] = useState(0);
return <>
<Button title='Submit' onClick={() => setClicks(count => count + 1)} />
<form hidden><input data-testid='click-count' readOnly value={String(clicks)} /></form>
</>;
};
```
Component test example: mount and interact
Example component test:
```js
import { test, expect } from '@playwright/test';
test('click should expand', async ({ mount }) => {
const component = await mount('components/Expandable/Stateful');
await component.getByRole('button').click();
await expect(component.getByTestId('expanded')).toHaveValue('true');
});
```
Component test with per-test props
Example component test with type-checked props:
```js
import { test, expect } from '@playwright/test';
import type { WithTitle } from '../../src/components/Button.story';
test('button with custom title', async ({ mount }) => {
const component = await mount<typeof WithTitle>('Button/WithTitle', { title: 'Hello' });
await expect(component.getByRole('button')).toHaveText('Hello');
});
```