Playwright Component Testing
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
A component test checks one piece of a user interface — a rating widget, an expandable description, a form field — in
a real browser, without starting the whole application and without logging in or navigating to the right page first.
Playwright 1.62 replaced its old experimental component-testing packages with a much smaller idea: the test runner
stays exactly what it is, and the component is rendered by a small page of your own, the gallery, that the built-in
mount fixture drives. This page explains that model, the contract the gallery must fulfil, every option involved, how
to write and type stories, and how to leave the discontinued @playwright/experimental-ct-* packages behind. It is
written for Playwright 1.64.1 (Node.js) and was checked on 25 October 2026. Component testing is documented for the Node.js
(JavaScript and TypeScript) runner only; the official page describes no equivalent for the Python, Java and .NET
bindings, whose examples were run at 1.63 (see
Playwright with Python, Java and .NET). The complete example below, a React and Vite gallery with two Bookshelf-style components
(a star rating for the review form and an expandable book description), was run in headless Chromium with
@playwright/test pinned to 1.64.1: 9 tests, all passing. It is a standalone mini project, so it does not need the
Bookshelf server itself; the components mirror two widgets of the application described in
Example Application & Test Layout.
The model: stories, a gallery and the mount fixture
Three concepts make up the whole mechanism:
-
A story is a tiny wrapper component that renders the component under test in one specific scenario: hard-coded props, mock data, providers, recorded callbacks. Stories live next to the component in files named
*.story.tsx(or.ts,.jsx,.jsor.vue), and every named export is one story. -
The gallery is a single page, served by your own dev server, that defines two functions on
window:window.mount(params)renders a story into an element with the idroot, andwindow.unmount()tears it down. It is the only framework-specific piece, and you own it. -
The
mountfixture of@playwright/testnavigates to the gallery, callswindow.mount()with the story id and the props, and returns aLocatorfor the gallery root element.
The test code runs in Node.js and the component runs in the browser, so a test performs real clicks, the browser does
real layout and a visual comparison is possible. At the same time the test gets everything Playwright Test offers:
parallel workers, retries, projects, fixtures, the trace viewer and the HTML report. There is no separate
component-testing runtime, no bundler integration inside Playwright and no extra npm package: tests import test and
expect from plain @playwright/test.
Everything the component needs is set up inside the story, because the story runs in the browser. Everything the test asserts must be observable through the page: the DOM, the URL or the network. That is why callbacks are not passed from the test: functions cannot cross the Node.js to browser boundary, so the story owns the state, supplies the callbacks and records the observable result into the page, where the test reads it with a web-first assertion.
This design replaced a very different one. The old @playwright/experimental-ct-* packages let a test write JSX
inline, as in mount(<Button onClick={spy} />). To make that work Playwright had to scan the tests for components,
compile a bundle with its own copy of Vite and its own configuration, serve it from its own server and marshal props and
callbacks between Node.js and the browser. The official documentation lists the consequences: path aliases, plugins and
CSS handling had to be mirrored by hand, projects on webpack, Next.js or custom pipelines could not use their own build,
every framework needed its own package, live objects could not cross the boundary and module mocks silently did not
apply. The gallery model inverts the control: your dev server builds and serves everything with your plugins, aliases
and CSS, and Playwright only navigates to a page.
What mount does exactly
The fixture is small enough to describe completely. For a call mount(storyId, props) it does the following:
-
Throws if no
baseURLis configured, with the message "mount() requiresbaseURLto point at the component gallery". -
Navigates the page to
baseURL. Everymount()is therefore a fresh navigation, which is why tests are isolated from one another and why mounting several stories in one test is cheap. -
Evaluates
window.mount({ story: storyId, props })in the page and awaits it. If the gallery did not definewindow.mount, the call throws "The gallery page does not define window.mount()". If the gallery’s function rejects (unknown story, an exception while rendering), the rejection becomes the exception thrown byawait mount(...), with a real stack, so there is no status code or DOM attribute to inspect. -
Returns
page.locator('#root')extended with two methods,update(newProps)andunmount().
| Returned object | Behaviour |
|---|---|
The |
Points at the element with the id |
|
Calls |
|
Calls |
The fixture is generic over the story: passing the story’s type as a template argument type-checks the props and the
arguments of update(). The second argument of mount carries plain serializable data only, because it is passed
through page.evaluate().
The gallery contract
The gallery is application code, not Playwright code. Its contract is deliberately tiny:
-
It is a single HTML page, usually kept under
playwright/gallery/, served by your own dev server. A Vite application serves it from the dev server it already runs, because Vite serves any HTML file under the project root; a project on another bundler runs a small standalone Vite server next to the application. The URL of this page is thebaseURLof the component project. -
window.mount({ story, props })resolves the story id to a component, renders it with the props into the element with the idrootand returns a promise that resolves once the component is mounted and rejects on failure. -
It reuses the rendering root across calls: the root is created on the first call and rendered into on every later call. Recreating the root on each call would reset component state, and
update()would then behave like a remount. -
window.unmount()unmounts the current story and returns a promise. -
It imports the global CSS the same way the application entry does. The body of
window.mountis also the natural place for application-wide setup: installing providers or plugins, seeding a store or starting an in-browser mock server before rendering. That function is the browser-side equivalent of the oldbeforeMountandafterMounthooks; there is no separate hook registry. -
Render failures should reject rather than be swallowed. In React that means wrapping the render in
flushSync, so an exception thrown while rendering reaches the promise.
Playwright ships this methodology as an agent skill. Running the command below in a project writes three skills
(playwright-cli, playwright-component-testing and playwright-trace) into .claude/skills/, and the
component-testing skill contains the full gallery specification with worked React and Vue examples. Asking a coding
agent to set up component testing with that skill makes it detect the framework and bundler, write the gallery, add the
Playwright project and write a first story and spec. The command was run, and it printed one "Skill installed" line per
skill. It has a --loop option with the values claude (the default) and agents.
npx playwright init-skills
Source: Component testing
Story ids and how mount resolves them
mount passes the story id to the gallery untouched, so resolution is the gallery’s job. The scheme Playwright
recommends, and the one its skill generates, is:
| Story file and export | Story id |
|---|---|
|
|
the same story, shorter |
|
|
|
The id is the path under src/ without the .story.* extension, a slash and the export name. Because ids are
strings, renaming or moving a story breaks the tests that name it at run time, not at compile time. Passing the story
type as a template argument ties the props to the story at compile time, and the skill documents a second, optional
layer: a small Vite plugin next to the gallery generates a stories.d.ts that augments the exported Stories interface
of @playwright/test, which types the story id itself, with autocomplete and rename safety, and the props that belong
to it. With generated types the ids carry the package name as a prefix, for example acme-ui/components/Button/WithTitle,
and any id that is not a key of Stories keeps untyped props, so adoption can be partial.
Configuring the component project
Component tests are an ordinary Playwright project. The configuration below, taken from the official documentation and
used unchanged by the example project, points baseURL at the gallery and starts the dev server.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'components',
testDir: './tests/components',
use: {
...devices['Desktop Chrome'],
baseURL: 'http://localhost:3919/playwright/gallery/index.html',
serviceWorkers: 'block',
reuseContext: true,
},
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3919/playwright/gallery/index.html',
reuseExistingServer: !process.env.CI,
},
});
Source: Component testing
The documentation uses port 5173, Vite’s default; the example uses 3919 because that port was free on the machine the page was written on. The options that matter:
| Option | Purpose |
|---|---|
|
Must be the URL of the gallery page, because |
|
A separate directory for component specs keeps them out of the end-to-end project, which has its own |
|
Stops the application’s own service worker from serving cached responses that would shadow your |
|
Reuses the browser context between tests in a worker instead of creating a new one per test. For component suites this
is a large speed-up, and it is the optimization the old packages applied implicitly. Isolation still holds, because
every |
|
Starts the dev server that serves the gallery and waits for the |
|
Any device descriptor or browser can be used; component specs run in headless Chromium, Firefox or WebKit like any other test, and several browser projects can share the same gallery. Only Chromium was run for this page. |
A complete example: Bookshelf-style components
The example project has its own package.json with @playwright/test pinned to 1.64.1 and Vite, the React plugin,
React 19 and TypeScript as development dependencies. Its layout:
ex-component-testing/
package.json scripts: "dev": "vite"
vite.config.ts port 3919, React plugin
playwright.config.ts the "components" project shown above
playwright/gallery/
index.html the gallery page with <div id="root">
main.tsx window.mount and window.unmount
src/components/
RatingStars.tsx the star rating of the review form
RatingStars.story.tsx two stories: Default, ReadOnly
Expandable.tsx the collapsible book description
Expandable.story.tsx two stories: Stateful, StartsExpanded
tests/components/
rating-stars.spec.ts
expandable.spec.ts
Vite needs only its default configuration, plus a fixed port so that the Playwright configuration and the dev server agree:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: { port: 3919, strictPort: true },
});
The components themselves are ordinary React components with no knowledge of Playwright. The expandable description is
controlled: it receives expanded and setExpanded from its parent.
import type { ReactNode } from 'react';
type Props = {
title: string;
expanded: boolean;
setExpanded: (expanded: boolean) => void;
children?: ReactNode;
};
export function Expandable({ title, expanded, setExpanded, children }: Props) {
return (
<section className="expandable">
<button type="button" aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>
{title}
</button>
{expanded && <div className="body">{children}</div>}
</section>
);
}
type Props = {
value: number;
onChange: (value: number) => void;
max?: number;
readOnly?: boolean;
};
export function RatingStars({ value, onChange, max = 5, readOnly = false }: Props) {
return (
<div className="stars" role="radiogroup" aria-label="Rating">
{Array.from({ length: max }, (_, i) => i + 1).map(n => (
<button
key={n}
type="button"
role="radio"
aria-checked={n === value}
aria-label={`${n} star${n === 1 ? '' : 's'}`}
disabled={readOnly}
onClick={() => onChange(n)}
>
{n <= value ? '★' : '☆'}
</button>
))}
</div>
);
}
Source: Component testing
The gallery
This is the whole framework-specific glue, adapted from the worked example in Playwright’s skill. import.meta.glob
collects every story file (Vite analyses it statically, which is why it has to live in this file), resolve implements
the id scheme described above, and the two window functions are the contract. The root is created once and reused, so
update() reconciles; flushSync makes a render error reject the promise; StrictMode matches how most applications
render. The stylesheet import mirrors what the application’s own entry point would import.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Component gallery</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
import { StrictMode } from 'react';
import { flushSync } from 'react-dom';
import { createRoot, type Root } from 'react-dom/client';
import '../../src/index.css';
// Vite analyzes import.meta.glob statically, so it stays in this file.
const stories = import.meta.glob('../../src/**/*.story.{tsx,jsx}');
const idOf = (file: string) => file.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 => idOf(f) === path || idOf(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 keeps state
// flushSync so that a render error rejects the promise instead of being swallowed
flushSync(() => root!.render(<StrictMode><Story {...props} /></StrictMode>));
};
(window as any).unmount = async () => {
root?.unmount();
root = undefined;
};
Source: Component testing
Because the gallery is a normal page of the dev server, opening http://localhost:3919/playwright/gallery/index.html
in a browser and calling await window.mount({ story: 'components/Expandable/Stateful' }) from the DevTools console
does exactly what the fixture does, which is the official way to debug a story by eye. A gallery may also list all
discovered stories on an optional index page.
Stories
Stories are where state and callbacks live. The Stateful story below owns the expanded state, supplies the
setExpanded callback to the component and records the value into a hidden form, so that the test can read it. The
StartsExpanded story takes a plain serializable prop, which a test can pass and later change with update().
import { useState } from 'react';
import { Expandable } from './Expandable';
export const Stateful = () => {
const [expanded, setExpanded] = useState(false);
return <>
<Expandable expanded={expanded} setExpanded={setExpanded} title='Description'>
A librarian discovers a door between shelves.
</Expandable>
<form hidden><input data-testid='expanded' readOnly value={String(expanded)} /></form>
</>;
};
export const StartsExpanded = ({ title = 'Description' }: { title?: string }) => {
const [expanded, setExpanded] = useState(true);
return <Expandable expanded={expanded} setExpanded={setExpanded} title={title}>
A librarian discovers a door between shelves.
</Expandable>;
};
Source: Component testing
The star rating stories record two things: the current value and every change, as a JSON array, in two separate inputs.
Recording each observed value in its own data-testid input, with String(...) for scalars and JSON.stringify(...)
for payloads, is the convention of the official documentation.
import { useState } from 'react';
import { RatingStars } from './RatingStars';
export const Default = ({ initial = 0 }: { initial?: number }) => {
const [value, setValue] = useState(initial);
const [changes, setChanges] = useState<number[]>([]);
return <>
<RatingStars value={value} onChange={v => { setValue(v); setChanges(c => [...c, v]); }} />
<form hidden>
<input data-testid='value' readOnly value={String(value)} />
<input data-testid='changes' readOnly value={JSON.stringify(changes)} />
</form>
</>;
};
export const ReadOnly = ({ value = 4 }: { value?: number }) =>
<RatingStars value={value} readOnly onChange={() => {}} />;
Source: Component testing
The conventions that keep a gallery useful as living documentation:
-
One export per scenario. Prefer a new story such as
DisabledorWithLongTitleover parameterizing an existing one: the list of exports ofButton.story.tsxthen reads as a specification of the button. -
Stories live next to the component, so a rename or refactor touches both together.
-
The story owns everything the component needs (providers, mock data, state, callbacks); the test owns only interactions and assertions.
-
Keep the recording form
hiddenfor a clean screenshot baseline, or drop the attribute while developing to watch the state change next to the component when you click it by hand.
If components need context (a theme, a router, a store), create one shared decorator component and use it inside the stories rather than inside the gallery, so every story states its scenario and nothing more, and stories can opt out. Create client objects such as a query client inside the story or decorator so that each navigation starts fresh.
Tests
The test for the expandable description is the one from the release notes, with an additional assertion on the visible
text. toHaveValue() is a web-first assertion, so it retries until the story’s state lands and there is nothing to
await or poll by hand.
import { test, expect } from '@playwright/test';
test('click should expand', async ({ mount }) => {
const component = await mount('components/Expandable/Stateful');
await expect(component.getByText('A librarian')).toBeHidden();
await component.getByRole('button').click();
await expect(component.getByTestId('expanded')).toHaveValue('true');
await expect(component.getByText('A librarian')).toBeVisible();
});
test('two clicks leave it collapsed', async ({ mount }) => {
const component = await mount('components/Expandable/Stateful');
await component.getByRole('button').dblclick();
await expect(component.getByTestId('expanded')).toHaveValue('false');
});
test('update() keeps component state', async ({ mount }) => {
const component = await mount<typeof import('../../src/components/Expandable.story').StartsExpanded>('Expandable/StartsExpanded');
await component.getByRole('button').click(); // collapse
await expect(component.getByText('A librarian')).toBeHidden();
await component.update({ title: 'Synopsis' });
await expect(component.getByRole('button', { name: 'Synopsis' })).toBeVisible();
await expect(component.getByText('A librarian')).toBeHidden(); // state preserved
});
test('unmount() removes the story', async ({ mount }) => {
const component = await mount('Expandable/Stateful');
await component.unmount();
await expect(component.getByRole('button')).toHaveCount(0);
});
test('an unknown story makes mount() throw', async ({ mount }) => {
await expect(mount('Expandable/DoesNotExist')).rejects.toThrow('Unknown story: Expandable/DoesNotExist');
});
test('visual baseline of the collapsed state', async ({ mount }) => {
const component = await mount('Expandable/Stateful');
await expect(component).toHaveScreenshot('collapsed.png');
});
Source: Component testing
The rating specs use typed props: the story type is imported with import type, so the story, React and the CSS are
never loaded into the Node.js test process, and update() is checked against the same type.
import { test, expect } from '@playwright/test';
import type { Default } from '../../src/components/RatingStars.story';
test('clicking a star sets the rating', async ({ mount }) => {
const component = await mount<typeof Default>('components/RatingStars/Default', { initial: 2 });
await expect(component.getByRole('radio', { name: '2 stars' })).toBeChecked();
await component.getByRole('radio', { name: '5 stars' }).click();
await expect(component.getByRole('radio', { name: '5 stars' })).toBeChecked();
await expect(component.getByTestId('value')).toHaveValue('5');
await expect(component.getByTestId('changes')).toHaveValue('[5]');
});
test('a read-only rating ignores clicks', async ({ mount }) => {
const component = await mount('RatingStars/ReadOnly', { value: 3 });
await expect(component.getByRole('radio')).toHaveCount(5);
await expect(component.getByRole('radio', { name: '1 star' })).toBeDisabled();
await expect(component.getByRole('radio', { name: '3 stars' })).toBeChecked();
});
test('update() changes props without remounting', async ({ mount }) => {
const component = await mount('RatingStars/ReadOnly', { value: 1 });
await expect(component.getByRole('radio', { name: '1 star' })).toBeChecked();
await component.update({ value: 4 });
await expect(component.getByRole('radio', { name: '4 stars' })).toBeChecked();
});
Source: Component testing
Running the project with npx playwright test printed the result below. The first run of the visual test had failed
with "A snapshot doesn’t exist … writing actual", which is how toHaveScreenshot always behaves when no baseline
exists yet (it writes the baseline, here collapsed-components-darwin.png, and fails that one run); the second run
passed. The platform suffix in the file name means baselines are per operating system, as explained in
Visual Regression Testing.
Running 9 tests using 2 workers
✓ 1 [components] › tests/components/rating-stars.spec.ts:4:1 › clicking a star sets the rating (299ms)
✓ 2 [components] › tests/components/expandable.spec.ts:3:1 › click should expand (299ms)
✓ 3 [components] › tests/components/rating-stars.spec.ts:15:1 › a read-only rating ignores clicks (147ms)
✓ 4 [components] › tests/components/expandable.spec.ts:13:1 › two clicks leave it collapsed (179ms)
✓ 5 [components] › tests/components/rating-stars.spec.ts:22:1 › update() changes props without remounting (142ms)
✓ 6 [components] › tests/components/expandable.spec.ts:19:1 › update() keeps component state (169ms)
✓ 7 [components] › tests/components/expandable.spec.ts:28:1 › unmount() removes the story (113ms)
✓ 8 [components] › tests/components/expandable.spec.ts:34:1 › an unknown story makes mount() throw (90ms)
✓ 9 [components] › tests/components/expandable.spec.ts:38:1 › visual baseline of the collapsed state (207ms)
9 passed (1.9s)
To confirm that the typing is real, a temporary file calling mount<typeof Default>('RatingStars/Default', { initial: 'three' })
was type-checked with tsc --noEmit, which rejected it with "Type 'string' is not assignable to type 'number'", and the
file was then removed.
Testing patterns
Record state for assertions
Components take callbacks and tests want to assert that they fired. Marshalling a callback from Node.js into the browser
is not possible, so the pattern above is the answer for every callback: the story creates the state, wires the callback
to it and writes the effect into the page. The scenario runs entirely in the browser, the recorded values are visible
when the story is opened in the gallery, and the negative case works the same way (perform the operation, then assert
that the value did not change). The official documentation shows the same pattern for Vue with defineComponent, ref
and h, with a 'onUpdate:expanded' handler writing to the ref.
Per-test props and typing
Pass plain serializable props as the second argument and read them in the story:
import type { WithTitle } from '../../src/components/Button.story';
const component = await mount<typeof WithTitle>('Button/WithTitle', { title: 'Hello' });
Source: Component testing
This snippet is quoted from the official documentation and was not run as written; the equivalent typed calls in the
example project, with RatingStars.story, were. Keep props to plain data: callbacks belong inside the story. For Vue
the skill adds that stories must also declare their props at runtime.
Prop transitions with update()
component.update(newProps) re-renders the same story with new props on the existing root, so state is preserved.
It needs a gallery that reuses its root, as the example’s does; the update() keeps component state test proves it by
collapsing the panel first and asserting that it is still collapsed after the title changed.
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');
Source: Component testing
This counter snippet is the official one and was not run, since the example project has no Counter story; the same
mechanism is exercised by the update() tests above.
Several states and visual comparison
Each mount() navigates afresh, so mounting several stories inside one test is cheap and does not leak state between
them. Screenshot the returned root locator, not the page, so that nothing else on the gallery page ends up in the
baseline. The options of toHaveScreenshot are in Visual Regression Testing.
await expect(await mount('Button/Primary')).toHaveScreenshot('primary.png');
await expect(await mount('Button/Disabled')).toHaveScreenshot('disabled.png');
Source: Component testing
Only the single-story screenshot of the example was run; this two-story snippet is quoted from the documentation.
Network requests
Use page.route() as in any other test, but register the routes before calling mount(), because mounting navigates
and the routes must be in place before the story fetches anything. serviceWorkers: 'block' in the project
configuration keeps a service worker from answering the request first. Teams that already have Mock Service Worker
handler libraries can start the worker inside a story or decorator instead. General routing is covered in
Network Mocking, Clock & API Testing.
test('renders the error state', async ({ page, mount }) => {
await page.route('**/api/items', route => route.fulfill({ status: 500 }));
const component = await mount('components/ItemList/Default');
await expect(component.getByRole('alert')).toContainText('Something went wrong');
});
Source: Component testing
This example is quoted from the official documentation and was not run, because the example components make no network requests.
What not to do
Reaching into a component’s methods or instance from test code is neither recommended nor supported. Click it, look at the page and record internal effects into the DOM through a story; a test that fails from the user’s perspective has usually found a real bug, and tests that avoid implementation details are less fragile. The design principles in Test Design & Page Objects apply to stories as well.
Frameworks and bundlers
The mount fixture does not know which framework is on the other side of window.mount. The official documentation
states that React, Vue, Svelte, Solid or anything else works, as long as the dev server can render it and the gallery
resolves a story id to something renderable and renders it into #root. In practice:
-
React and Vue have worked examples in the skill: a React gallery with
createRoot,flushSyncand a reused root, and a Vue gallery that mounts a small reactive host once and updates its refs, becausecreateApp(...).mount()would build a fresh instance on every call and lose state. React needs version 18 or later forcreateRoot. -
Vite applications serve the gallery from the existing dev server; Vite ignores the extra HTML file in production builds.
-
Other stacks (Next.js, webpack, no dev server at all) run a small standalone Vite server for the gallery, with Vite and the framework plugin as development dependencies, and
baseURLpointing at that server. -
For Svelte, Solid or any other framework, the documentation’s advice is to ask a coding agent to implement the gallery contract: resolve a story id, render into
#root, reuse the root across calls so thatupdate()preserves state. Only React was run for this page; the Vue and other-framework statements come from the documentation.
Discontinued: the @playwright/experimental-ct-* packages
Before 1.62 Playwright component testing was a separate experimental feature with one package per framework:
@playwright/experimental-ct-react, @playwright/experimental-ct-react17, @playwright/experimental-ct-vue,
@playwright/experimental-ct-svelte and @playwright/experimental-ct-solid. Tests imported test and expect from
the package, wrote JSX inline, and configured the bundling with defineConfig options such as ctViteConfig, ctPort
and ctTemplateDir, a playwright/index.html template, beforeMount and afterMount hooks and a router fixture.
The status of each package in the npm registry on 25 October 2026 (from npm view):
| Package | Latest version | Published | Registry state |
|---|---|---|---|
|
1.62.1 |
30 July 2026 |
|
|
1.62.1 |
30 July 2026 |
same as above |
|
1.62.1 |
30 July 2026 |
same as above |
|
1.58.2 |
6 February 2026 |
no stable release since; last |
|
1.48.2 |
25 October 2024 |
deprecated, with the registry message "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info." |
The official page says the react, react17 and vue packages "have been removed and are no longer published", and that projects still on them should stay on Playwright 1.62 until they have migrated. The registry shows what that means in practice: the last stable versions, 1.62.1, can still be installed, but nothing newer than 1.62.1 was released, so they cannot follow Playwright 1.63 or later. The Svelte and Solid packages stopped earlier, at 1.58.2 and 1.48.2, and the documentation’s migration guide does not mention them by name; the gallery approach is the replacement for every framework.
Migration path
The recommended path is incremental, and the official documentation gives the mapping between the two models:
| Experimental packages | Story gallery |
|---|---|
|
A stateful story supplies |
Plain data props from the test |
Unchanged in spirit: |
JSX children or slots from the test |
One story export per composition (in Vue, a |
|
|
|
|
|
The body of the gallery’s |
|
Props interpreted by the story, for example |
|
|
|
The gallery’s |
|
Gone: the gallery runs through your own dev server, and the port lives in |
|
Plain |
A typical spec changes like this. Before, the callback was a closure in the test:
import { test, expect } from '@playwright/experimental-ct-react';
import Button from '../src/components/Button';
test('counts clicks', async ({ mount }) => {
let clicks = 0;
const component = await mount(<Button title='Submit' onClick={() => ++clicks} />);
await component.getByRole('button').click();
expect(clicks).toBe(1);
});
After, the closure becomes state in a story and the assertion becomes a web-first one:
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>
</>;
};
import { test, expect } from '@playwright/test';
test('counts clicks', async ({ mount }) => {
const component = await mount('components/Button/CountsClicks');
await component.getByRole('button').click();
await expect(component.getByTestId('click-count')).toHaveValue('1');
});
Source: Component testing
These before-and-after snippets use a Button component that the example project does not have, so they were not run
as written; the same story-and-recorded-value mechanism is what the Expandable and RatingStars tests run.
The steps the documentation recommends:
-
While still pinned to Playwright 1.62, set up the gallery and the
componentsproject next to the old component testing project. -
Port the specs one by one: a callback becomes state in a story, a per-test JSX tree becomes one story export per composition, hooks move into the gallery.
-
Remove the
@playwright/experimental-ct-*dependency together withplaywright/index.html,playwright/index.tsand theplaywright/.cachedirectory, and upgrade Playwright.
Two things to watch: story ids are strings, so a rename breaks specs at run time (use the typed mount<typeof Story>
form to catch prop mismatches at compile time), and per-test JSX no longer exists, which is deliberate, since every
composition worth testing is worth naming and reviewing.
Alternatives
Playwright is not the only way to test components in a real browser. Cypress has its own component testing mode that mounts components through a framework adapter inside the Cypress runner (Cypress component testing), Vitest can run component tests in a real browser through its browser mode (Vitest Browser Mode), and Storybook offers a documented way to test the stories you already maintain for documentation (Storybook testing). All three pages were reachable on 25 October 2026; none of the three tools was run for this page. A Playwright gallery is the lightest choice when the project already uses Playwright for end-to-end tests and wants one runner, one configuration and the same traces and reports for both levels.
What changed recently
| Version | Change |
|---|---|
1.62 |
New component testing model: stories and a gallery page, and the |
1.63 and later |
No component-testing change in the release notes; the experimental packages publish no stable releases after 1.62.1. Version 1.64.1 is the latest at the check date, and the example ran on it. |
1.58 and earlier |
|