Home / Blog / Playwright component testing
Playwright component testing: what a real browser buys, and what it costs
Playwright component testing renders one component in a real browser and drives it with the
same locators and assertions as an end-to-end test. In 1.62 the model is stories and
galleries: a story export beside the component, a gallery page your own dev server serves,
and await mount('components/FilterPopover/Default') in the test. It replaces the
experimental @playwright/experimental-ct-* packages. It is slower than a jsdom
suite and it needs that dev server.
A filter panel opens underneath a sticky bulk-actions bar, and the component test covering it is green. In a simulated DOM the checkbox is in the tree, the change handler fires when the test clicks it, and nothing in the run knows the element is buried under two hundred pixels of another div.
What is Playwright component testing?
The model is a story, a gallery and a fixture, and all three are documented on
the component testing page. A
story is a small wrapper component that puts the component under test into one
scenario — fixed props, mock data, providers, recorded callbacks. Stories live beside the
component in a *.story.tsx file, one named export per scenario. The
gallery is a single page your own dev server serves. It puts
window.mount() and window.unmount() on the page and renders a story
into a #root element. The mount fixture navigates to the gallery,
calls window.mount() with the story id, and returns a Locator for that
root.
The story runs in the browser, and the test only ever observes through the page. Anything the component needs is set up inside the story. Anything the test asserts has to be visible in the DOM, the URL or the network.
mount arrives as a built-in fixture of @playwright/test, the same way
page does; how Playwright fixtures work goes
into the mechanism behind that.
import { FilterPopover } from './FilterPopover';
const options = [
{ id: 'unpaid', label: 'Unpaid' },
{ id: 'overdue', label: 'Overdue' },
{ id: 'draft', label: 'Draft' },
];
export const Default = ({ resultCount = 128 }: { resultCount?: number }) => (
<div className="screen">
<FilterPopover options={options} resultCount={resultCount} />
<div className="action-bar below">Bulk actions</div>
</div>
);
export const UnderActionBar = () => (
<div className="screen">
<FilterPopover options={options} resultCount={128} />
<div className="action-bar above">Bulk actions</div>
</div>
);
Playwright 1.62 · src/components/FilterPopover.story.tsx
Every sample on this page is the stories-and-galleries model that landed in 1.62, run on
Playwright 1.62.1 against Chromium 151.0.7922.34, with React 19.2.0 and Vite 7.1.9. If you find
a < after mount( anywhere else, you are reading a page written for
the packages this model replaced.
The two exports are two scenarios of the same invoice toolbar. In one the popover's stacking
context wins; in the other a sticky bulk-actions bar sits on top of it. The id is the path under
src/ without the .story. extension, plus the export name, so
Default is components/FilterPopover/Default. The documentation adds
that any unique trailing suffix resolves as well, and FilterPopover/Default mounted
the same story on this bench.
import { test, expect } from '@playwright/test';
test('the filter panel opens over the bulk action bar', async ({ mount }) => {
const component = await mount('components/FilterPopover/Default');
await component.getByRole('button', { name: 'Filters' }).click();
await component.getByRole('checkbox', { name: 'Unpaid' }).check();
await component.getByRole('button', { name: 'Apply' }).click();
await expect(component.getByRole('button', { name: 'Filters (1)' })).toBeVisible();
});
Playwright 1.62 · tests/components/filter-popover.spec.ts
Nothing here imports the component. The test names a string, and every query is scoped from the
locator that comes back rather than from page — the call log prints it as
locator('#root'), which is what the gallery rendered into.
You already have component tests. What does a browser add?
Four answers a simulated DOM does not have:
- Layout. Element boxes, overflow, stacking order, one element covering another.
- CSS.
display: none,visibility, media and container queries. A component hidden by a stylesheet is still found by a jsdom query and is correctly invisible totoBeVisible(). - Events. A real click with actionability behind it, real focus, a real keyboard and a real pointer, instead of a synthetic event dispatched at a node.
- More than one engine. The same component in Chromium, Firefox and WebKit. The WebKit here is the engine build Playwright ships, and it is not the Safari that runs on a phone, so a component whose sign-off has to happen on an iOS device still goes to a device.
The UnderActionBar story makes the first of those concrete. Same component, same
two interactions, run twice. Under Vitest 3.2.4 with jsdom 27.0.0 and Testing Library 16.3.0 it
passes:
import { expect, test } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UnderActionBar } from '../src/components/FilterPopover.story';
import '../src/styles.css';
test('the filter panel opens under the bulk action bar', async () => {
render(<UnderActionBar />);
await userEvent.click(screen.getByRole('button', { name: 'Filters' }));
await userEvent.click(screen.getByRole('checkbox', { name: 'Unpaid' }));
expect(screen.getByRole('button', { name: 'Filters (1)' })).toBeDefined();
});
Vitest 3.2.4 · jsdom-tests/stacking.test.tsx
Under Playwright it does not. The check call carries a five-second timeout so the
transcript fits on a page; with the default it fails the same way and prints more of it.
TimeoutError: locator.check: Timeout 5000ms exceeded.
Call log:
- waiting for locator('#root').getByRole('checkbox', { name: 'Unpaid' })
- locator resolved to <input type="checkbox"/>
- attempting click action
2 × waiting for element to be visible, enabled and stable
- element is visible, enabled and stable
- scrolling into view if needed
- done scrolling
- <div class="action-bar above">Bulk actions</div> intercepts pointer events
- retrying click action
- waiting 20ms
2 × waiting for element to be visible, enabled and stable
- element is visible, enabled and stable
- scrolling into view if needed
- done scrolling
- <div class="action-bar above">Bulk actions</div> intercepts pointer events
- retrying click action
Playwright 1.62.1 · Chromium 151.0.7922.34 · trimmed after the second retry
Read the middle of that log. Element is visible, enabled and stable —
toBeVisible() would have passed. What fails is the click: actionability includes a
hit test, and the bar is what the pointer would land on. The overlap gets caught at the action,
and no assertion on the page would have found it.
The bill for that:
- A browser launch and a page navigation, where the other suite has a module import.
- A dev server that has to be running, locally and in the pipeline.
- A gallery page you write, own and maintain.
- A story file per scenario, sitting beside the component as real code.
- A second way of writing tests in a repository that already has one.
So the answer is not a migration. Add a browser project for the components where the browser is the thing under test, and leave the rest in the suite that is already fast.
Where does a component test stop and an end-to-end test start?
A component test owns everything inside the component's own boundary — its props, its states, its rendering, its keyboard and pointer behaviour, its roles and names, and what it emits.
An end-to-end test owns everything that crosses one: routing, auth, real data, a flow over more than one screen, and anything where the answer to did it work lives on a server.
The tell that you wrote the wrong one is in the story. If it has grown providers, a router, a seeded store and four mocked endpoints to support a single assertion, that assertion belongs in an end-to-end test. The story has quietly become a second application and it will drift from the real one.
Component tests do not replace end-to-end coverage of a flow, and nothing on this page should
be read as saying they do. Routing works the same inside a component test as anywhere else:
register page.route() before mount(), because mounting navigates. See
mocking network requests in Playwright.
Setting this up is a build problem, not a testing one
Playwright compiles nothing and serves nothing here. mount() navigates to
baseURL, so baseURL points at the gallery URL and webServer
starts whatever serves it.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'components',
testDir: './tests/components',
use: {
...devices['Desktop Chrome'],
baseURL: 'http://localhost:5173/playwright/gallery/index.html',
serviceWorkers: 'block',
reuseContext: true,
},
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173/playwright/gallery/index.html',
reuseExistingServer: !process.env.CI,
},
});
Playwright 1.62 · playwright.config.ts
reuseExistingServer: !process.env.CI is the line that makes both halves bearable.
Locally it attaches to the dev server you already have open. On CI, where the variable is set, it
refuses to attach to a stray one and starts its own. serviceWorkers: 'block' stops
the app's own service worker serving cached responses over the top of your
page.route() mocks, and reuseContext: true keeps one browser context
per worker instead of one per test.
That makes the setup questions build questions. Which dev server. Which port. Whether it comes up clean in CI. Whether the gallery route exists in the production build or only in dev. What happens when the app's dev server wants environment variables the pipeline does not have.
The gallery itself is application code, and Playwright ships no template for it. The documented
first move is npx playwright init-skills followed by asking a coding agent to
implement it, which is a real answer — 1.62.1 does install that skill, and the contract is
written out inside it. A team with no agent in the loop still has to know what the page must do,
and it is three obligations:
- Resolve a story id to a component, and reject when it cannot, because that rejection is what
surfaces as your
mount()call throwing. - Render into an element with
id="root", because that element is the locatormount()hands back. - Create the root once and render into it on every call, so
update()reconciles instead of remounting and component state survives.
import type { ComponentType } from 'react';
import { flushSync } from 'react-dom';
import { createRoot, type Root } from 'react-dom/client';
import '../../src/styles.css';
const modules = import.meta.glob<Record<string, ComponentType<any>>>('../../src/**/*.story.tsx');
const modulePath = (file: string) =>
file.replace(/^(\.\.\/)+src\//, '').replace(/\.story\.tsx$/, '');
async function resolveStory(storyId: string) {
const cut = storyId.lastIndexOf('/');
const path = storyId.slice(0, cut);
const exportName = storyId.slice(cut + 1);
const file = Object.keys(modules).find(candidate => {
const id = modulePath(candidate);
return id === path || id.endsWith(`/${path}`);
});
if (!file)
throw new Error(`No story file for "${storyId}". Known: ${Object.keys(modules).map(modulePath).join(', ')}`);
const module = await modules[file]();
const Story = module[exportName];
if (!Story)
throw new Error(`"${modulePath(file)}" has no export named "${exportName}"`);
return Story;
}
let root: Root | undefined;
window.mount = async ({ story, props }) => {
const Story = await resolveStory(story);
root ??= createRoot(document.getElementById('root')!);
flushSync(() => root!.render(<Story {...props} />));
};
window.unmount = async () => {
root?.unmount();
root = undefined;
};
declare global {
interface Window {
mount(params: { story: string, props?: Record<string, unknown> }): Promise<void>;
unmount(): Promise<void>;
}
}
Playwright 1.62 · playwright/gallery/main.tsx · React 19 + Vite 7
Forty-six lines, and they are yours from here on. import.meta.glob is Vite's, and
it is analysed statically and relative to the file it sits in, which is why story discovery
cannot be lifted into a package. The framework-specific glue is the part nobody can ship for
you, and it is what this model costs.
Writing a story worth testing
The adjustment for anyone arriving from Testing Library, or from the -ct-
packages, is that a callback the test counts has nowhere left to be counted. Nothing crosses the
Node-to-browser boundary any more. So the story provides the callback, holds the result, and
records it into a hidden form the test can read.
import { useState } from 'react';
import { SearchBox } from './SearchBox';
export const Recording = () => {
const [terms, setTerms] = useState<string[]>([]);
return (
<>
<SearchBox onSearch={term => setTerms(current => [...current, term])} />
<form hidden>
<input data-testid="terms" readOnly value={JSON.stringify(terms)} />
</form>
</>
);
};
Playwright 1.62 · src/components/SearchBox.story.tsx
The assertion is then
await expect(component.getByTestId('terms')).toHaveValue('["overdue"]'), which is a
web-first assertion and retries until the state lands. Use String(...) for a scalar
and JSON.stringify(...) for a payload. Drop the hidden attribute while
you are working on the story and the recorded values render next to the component in the gallery,
which turns the story into a manual test page for the same scenario.
The other half of the idiom is update(). A story can take plain serializable props
from the test, and update() re-renders it on the existing root, so a prop transition
is one test instead of two.
import { test, expect } from '@playwright/test';
import type { Default } from '../../src/components/FilterPopover.story';
test('a new result count does not close the open panel', async ({ mount }) => {
const component = await mount<typeof Default>('FilterPopover/Default', { resultCount: 128 });
await component.getByRole('button', { name: 'Filters' }).click();
await component.getByRole('checkbox', { name: 'Unpaid' }).check();
await expect(component.getByTestId('result-count')).toHaveText('128 results');
await component.update({ resultCount: 42 });
await expect(component.getByTestId('result-count')).toHaveText('42 results');
await expect(component.getByRole('checkbox', { name: 'Unpaid' })).toBeChecked();
});
Playwright 1.62 · tests/components/update.spec.ts
The panel is open and a box is ticked when the result count changes from 128 to 42, and both
are still true afterwards. That is the gallery reusing its root, and it is why the third
obligation above is on the list. mount<typeof Default> also type-checks the
props against the story's signature, which is the only compile-time tie between a spec and the
story it names.
Vue works the same way through a different gallery. The story is a
defineComponent, and the gallery mounts one small reactive host and then updates its
refs, because createApp().mount() builds a fresh instance on every call and would
throw the state away. Past React and Vue the contract is unchanged and there is no shipped
implementation of it, so you write that file yourself.
When this bites you
- You cannot reach into the component. Its instance and its internals are out of bounds from test code, and the documentation neither recommends nor supports getting at them. A Testing Library suite that leans on instance access or module mocks does not port; that code is re-expressed through the story.
- The gallery is a second render path. Your app renders a component one way and the gallery renders it another. Providers, theming and global CSS have to be right in both, and when they drift the component test passes against a rendering no user ever sees. Importing the app's global stylesheet in the gallery entry, exactly as the app entry does, removes most of that risk.
- Story ids are strings. Rename or move a story and the spec breaks when it runs. Nothing catches it at compile time: the generic ties the props to the story and leaves the id alone.
- The dev server becomes a CI dependency. A component suite that cannot start the app's dev server in the pipeline never runs. That is a worse failure than a flaky test, and a slower one to work out.
- It is a younger surface than the runner around it. Component testing became stable in 1.62. The end-to-end runner has years behind it.
- It is Node only. The
mountfixture belongs to the JavaScript and TypeScript test runner. A run of both bindings on the same day found no equivalent in the Python install, one of several gaps between Playwright's TypeScript and Python bindings.
Migrating off the experimental packages
The packages are superseded, and they are still published.
@playwright/experimental-ct-react and @playwright/experimental-ct-vue
both have a 1.62.1, and an old spec still runs against that version: a second bench for this
article did exactly that, and printed Playwright's own copy of Vite building a bundle into
playwright/.cache on the way past. Nothing read on the documentation says when the
packages will be removed, so this is a move to plan and nothing is forcing it this sprint.
import { test, expect } from '@playwright/experimental-ct-react';
import { SearchBox } from '../src/components/SearchBox';
test('records the search term', async ({ mount }) => {
const terms: string[] = [];
const component = await mount(<SearchBox onSearch={term => terms.push(term)} />);
await component.getByRole('textbox', { name: 'Search' }).fill('overdue');
await component.getByRole('button', { name: 'Go' }).click();
expect(terms).toEqual(['overdue']);
});
Superseded · @playwright/experimental-ct-react 1.62.1 · tests/search-box.spec.tsx
The same test on the current model. It mounts the Recording story from the section
above, and the counter that used to live in the test now lives in the browser:
import { test, expect } from '@playwright/test';
test('submitting the form records the search term', async ({ mount }) => {
const component = await mount('components/SearchBox/Recording');
await component.getByRole('textbox', { name: 'Search' }).fill('overdue');
await component.getByRole('button', { name: 'Go' }).click();
await expect(component.getByTestId('terms')).toHaveValue('["overdue"]');
});
Playwright 1.62 · tests/components/search-box.spec.ts
Here is what a migration touches:
- The import moves to plain
@playwright/test. - JSX written in the test becomes a story export beside the component, one per composition.
- A callback counted in the test becomes state recorded into the DOM, because there is no boundary left to marshal it across.
component.update(<Button count={2} />)becomescomponent.update({ count: 2 }).- The duplicated Vite config goes, and with it
ctViteConfig,ctPortandplaywright/index.html.
The mechanical part is per spec, and the documentation describes running both projects side by side while you port. The part that is not mechanical is the assertion: every spy in the old suite has to be re-expressed as something observable in the page. Do not budget that as an import change.
So should you do this?
Keep the fast suite. Add a browser project for the overlay, the focus trap, the scroll container and anything else whose bug is a bounding box. Leave the flow tests in the end-to-end suite where they belong. If that means two runners in the repository, two is the correct number, and the case for cutting it to one is about tidiness.
Working out which components are in that second group is a reading job on a repository that already exists, and it is the same reading that starts a Playwright suite build.
Questions
Is Playwright component testing still experimental?
No. In 1.62 mount is a built-in fixture of plain @playwright/test and there is no separate package to install. Under Why a framework-agnostic approach the documentation puts it in three words: "It is stable." The @playwright/experimental-ct-react and @playwright/experimental-ct-vue packages are superseded by the stories-and-galleries model, and nothing published says when they will be removed.
Can Playwright component testing replace Vitest or Jest?
It can, and for most teams it should not. You would trade a module import for a browser launch and a page navigation, and take on a dev server that has to start in CI. The components worth moving are the ones whose failures are geometric, such as an overlap, a focus trap or a media query. The rest are cheaper where they are.
Do I need a dev server to run Playwright component tests?
Yes. Playwright compiles nothing and serves nothing in this model: mount() navigates to the gallery page and your own dev server is what serves it. That is why baseURL points at a gallery URL and why webServer sits in the config, with reuseExistingServer: !process.env.CI so local runs attach to a server you already have open and CI starts its own.
Can I take a screenshot of a mounted component?
Yes. mount() returns a locator for the gallery root and toHaveScreenshot() works against it, which frames the component instead of the page. The baseline still belongs to the browser and the operating system that wrote it, and visual regression that survives CI covers where to generate one.
Send the five components you trust least
Name them, and say what each one does on the screen: a dialog, a date picker, a table that goes horizontal below 700 pixels. You get back a split — the ones a browser would catch something on, and the ones that are fine where they are. If you would rather have the suite you already run read first, the audit is optional and billed by the hour, and most clients arrive knowing the job without it.