Home / Blog / Playwright component testing

Playwright component testing: what a real browser buys, and what it costs

Quick answer

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:

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 stabletoBeVisible() 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:

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:

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

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 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.