PRACTICAL GUIDE / Playwright Vue component testing setup
Set up Vue component tests without a second bundler
Run Vue stories through Playwright's stable mount fixture, reuse the app's Vite pipeline, diagnose gallery failures, and migrate old CT suites.
In this guide6 sections
What you will learn
- Check the version before choosing an architecture
- Point Playwright at a gallery your dev server owns
- Put stateful behavior inside Vue stories
- Diagnose the first broken boundary, not the final assertion
Your Vue component renders in the application, then its component test fails on an alias, plugin, or stylesheet. The usual cause is a test-only build pipeline that no longer matches production. Current Playwright component testing removes that second pipeline: the application dev server renders named stories, and the standard test runner drives them in a real browser.
Check the version before choosing an architecture
Playwright's component-testing model changed. The current stable approach uses the built-in mount fixture from @playwright/test. The fixture is documented as added in Playwright 1.62. Older examples based on @playwright/experimental-ct-vue, JSX inside the test, ctViteConfig, and a Playwright-managed Vite bundle describe the experimental architecture. Mixing the two produces configuration that looks plausible but cannot work.
Start by checking the installed binary, not the version range you remember from package.json. A caret range may allow a newer release while the lockfile still installs an older one. A globally installed command can also differ from the project dependency. Run through the project's package executor and record the result in the migration issue.
npx playwright --version
npm ls @playwright/test
npx playwright test --listIf the project is below 1.62, the stable mount fixture is unavailable. Upgrade Playwright and its browser binaries through the repository's normal dependency process before writing stable-gallery tests. Do not add a local declaration to silence TypeScript or invent a custom mount fixture with the same name. That would make the test compile while describing behavior the installed runner does not provide.
The architectural change matters more than the package name. The experimental Vue package scanned tests, compiled component references through its own Vite integration, served a private test page, and marshalled some values across the Node and browser boundary. That setup needed aliases, plugins, CSS handling, and environment behavior duplicated into CT configuration. A production Vite change could leave component tests on a stale approximation.
The stable model makes your dev server the owner. A small gallery page discovers story modules and exposes window.mount() and window.unmount(). A story is ordinary Vue code that imports the component, supplies props and providers, owns callbacks, and records observable state. Playwright navigates to that gallery, asks it to render a story into its root, and returns a Locator scoped to that root.
This means there is no framework-specific Playwright runtime and no extra Vue CT package for a new suite. It does not mean there is zero setup. Your repository owns the gallery page and its story-resolution convention. The official component-testing guide ships a Playwright agent skill that can generate the app-specific gallery contract; npx playwright init-skills installs those instructions. The command does not silently rewrite the app by itself, so review the generated gallery like any other test infrastructure.
Use one architecture per test during migration. An old spec can continue importing from @playwright/experimental-ct-vue while a new spec uses @playwright/test, provided their configurations and commands remain distinct. Do not import the stable test object and expect it to understand experimental inline Vue mounting. Do not carry ctPort or ctViteConfig into the new project; current documentation explicitly maps those settings to the app's own webServer and baseURL.
The version gate should be visible in documentation and CI. A developer with an old lockfile should get a clear dependency error rather than spend an afternoon debugging Vue. Pinning a known working Playwright release is reasonable for a production suite. Upgrade intentionally, install the matching browsers, and run the component project before merging the lockfile change.
Point Playwright at a gallery your dev server owns
The gallery is a real browser page, often under playwright/gallery/. It contains a root element, loads an entry module, discovers named story exports, and implements the two window functions required by the fixture. The exact Vue implementation depends on the bundler and repository layout, which is why the official skill generates it for the project rather than publishing one universal file.
The contract is small enough to diagnose. window.mount({ story, props }) must resolve the story identifier, render it into the gallery root, and reject an unknown story or render error. window.unmount() must tear down the current story. The gallery should reuse its Vue rendering root for component.update(props) so a prop update reconciles the component instead of replacing all state. Global CSS and app-wide plugins belong in this browser-side pipeline.
Configure a dedicated Playwright project whose baseURL is the gallery page. The built-in fixture navigates to that URL on each mount. The webServer block starts the same development command the team uses for the app and waits for the gallery URL to answer.
import { defineConfig, devices } from '@playwright/test';
const appURL = process.env.APP_URL ?? 'http://127.0.0.1:5173';
export default defineConfig({
reporter: [['html', { open: 'never' }], ['line']],
retries: process.env.CI ? 1 : 0,
projects: [
{
name: 'components',
testDir: './tests/components',
use: {
...devices['Desktop Chrome'],
baseURL: `${appURL}/playwright/gallery/index.html`,
serviceWorkers: 'block',
trace: 'on-first-retry',
},
},
{
name: 'e2e-chromium',
testDir: './tests/e2e',
use: {
...devices['Desktop Chrome'],
baseURL: appURL,
trace: 'on-first-retry',
},
},
],
webServer: {
command: 'npm run dev -- --host 127.0.0.1',
url: `${appURL}/playwright/gallery/index.html`,
reuseExistingServer: !process.env.CI,
},
});The example leaves reuseContext off. Playwright's component guide shows it as a speed optimization, while the current API documentation marks it discouraged and describes its reset as best-effort. Permissions granted during a test are one documented example of state that is not reset. Begin with fresh contexts so failures have a clean baseline. Enable reuse only after measuring a meaningful cost, audit state your stories can leak, and keep a focused isolation test.
serviceWorkers: 'block' is useful when component tests use page.route() to control initial API requests. Otherwise an application service worker can answer from its own cache and shadow the route. Blocking it changes the environment, so keep an end-to-end project that exercises service-worker behavior if the product depends on offline caching. Component isolation should not erase that separate coverage need.
The testDir values must not overlap. If component specs sit under the end-to-end directory and both projects discover them, the E2E project may try to run a mount spec with the wrong base URL. Use npx playwright test --list --project=components to inspect discovery. A clean list is stronger evidence than assuming a filename suffix excludes the right files.
The host and port need one owner. If CI injects APP_URL, the dev command must actually listen there. webServer.url is a readiness check, not a rewrite rule. A port mismatch yields connection refusal before Vue code runs. A gallery path that returns the app's fallback index may answer with 200 yet omit window.mount, producing a later and more specific contract failure.
reuseExistingServer is convenient locally but can connect tests to a dev server started with different environment variables or an older checkout. When a story behaves impossibly, stop the existing server and let Playwright start the configured command. CI should keep reuse disabled so the job owns both the process and its logs.
If the repository runs several Vite applications, give the gallery an explicit app and port rather than relying on whichever server answers first. The readiness URL should be a real gallery resource, not only the host root. That makes a wrong application fail during startup instead of surfacing later as a missing story or missing window contract.
Keep environment branching out of stories when possible. The gallery can read the same Vite environment variables as the app, but a component story should use explicit data rather than a production API key or live service. Deterministic stories make the gallery useful for manual review as well as automation.
Put stateful behavior inside Vue stories
A story owns the browser-side objects that cannot sensibly cross into the Node test. That includes Vue refs, callbacks, slots, plugins, and provider instances. The test should interact through the DOM and assert a user-observable result. This removes callback marshalling and keeps the scenario available when a developer opens the gallery directly.
The official Vue pattern uses defineComponent, h, and ref to wrap a component. In this example, Expandable emits an update:expanded event. The story accepts it, updates a ref, and records the state in a hidden input. The hidden form is diagnostic test output owned by the story; it is not an invented Playwright API.
import { defineComponent, h, ref } from 'vue';
import Expandable from './Expandable.vue';
export const Stateful = defineComponent(() => {
const expanded = ref(false);
return () => h('div', [
h(Expandable, {
expanded: expanded.value,
'onUpdate:expanded': (value: boolean) => {
expanded.value = value;
},
title: 'Release details',
}),
h('form', { hidden: true }, [
h('input', {
'data-testid': 'expanded-state',
'readonly': true,
'value': String(expanded.value),
}),
]),
]);
});The test imports the regular Playwright API and mounts the story by identifier. Scope queries from the returned component locator. A page-wide button query can accidentally match gallery controls added later, while component.getByRole() stays inside the rendered root.
import { expect, test } from '@playwright/test';
test('expands details and reports the emitted state', async ({ mount }) => {
const component = await mount('components/Expandable/Stateful');
await component.getByRole('button', { name: 'Release details' }).click();
await expect(component.getByTestId('expanded-state')).toHaveValue('true');
await expect(component.getByRole('region', { name: 'Release details' })).toBeVisible();
});This oracle can fail if the component stops emitting, if the story stops updating state, or if the region no longer becomes visible. It does not increment a Node variable and immediately check the same variable. The observable bridge is updated by the component's real browser callback.
Keep one named export per scenario when composition changes. Primary, Disabled, WithLongTitle, and InsideDialog are reviewable states. A single story with ten boolean props can create combinations the component never supports and make screenshots difficult to identify. Plain serializable props are useful for data variation, and component.update() can test a prop transition without remounting. Functions and live Vue objects belong inside the story.
Providers should be explicit. A component requiring Pinia, Vue Router, internationalization, or a theme can receive them in a story wrapper or a gallery-level decorator. Put global app invariants in the gallery entry and scenario-specific state in the story. Installing every production plugin globally can make a small component pass only because unrelated app bootstrap code ran.
Slots deserve named compositions. A slot-heavy .story.vue file often reads more clearly than deeply nested h() calls. The stable methodology allows ordinary Vue story modules, so use the syntax the team can review. The important rule is that the composition executes in the browser through the app's build pipeline.
CSS needs a contract too. Import the same global reset, design tokens, and component styles the application entry uses. A component screenshot without those imports may be consistently wrong rather than flaky. Conversely, importing the entire production shell can hide missing component-local styles. Decide which styles are global dependencies and document them in the gallery entry.
Do not reach into a Vue component instance from the test. Current Playwright component documentation does not support component-instance methods as the testing surface. Click, type, inspect roles, assert URL or network output, and record callback state in the story. If a private method needs direct testing, extract its logic into a unit-testable module rather than exposing it through the gallery.
Diagnose the first broken boundary, not the final assertion
Component setup has four distinct boundaries: the dev server must serve the gallery, the gallery must install its window contract, the resolver must find the story, and Vue must render the scenario. A final message such as “button not found” is useful only after the earlier boundaries have passed.
page.goto: net::ERR_CONNECTION_REFUSED points to the server, host, or port. Check the webServer command output and open the exact baseURL. Do not edit the component locator. A 404 points to gallery routing or Vite base-path configuration. A 200 response containing the application's ordinary index can still be wrong if the fallback swallowed the gallery path.
window.mount is not a function means navigation completed but the required browser function was absent. Inspect the gallery entry script in DevTools, browser console syntax errors, and module loading. A Vue component has not necessarily been imported yet. Changing a story ID cannot repair a missing global contract.
An “unknown story” error moves the investigation one step later. The gallery works, but its resolver did not map the string to a named export. Story IDs conventionally combine the path below src without .story.* and the export name, but your gallery owns the resolver. Case differences matter on Linux even when a macOS checkout appears forgiving. Rename a story and every string ID must follow.
Type the props against the story when variation matters. The built-in fixture accepts a story type as a generic argument, which catches invalid serializable props at compile time. It cannot make the story ID string rename-safe. Keep stories close to their components, use predictable IDs, and include the component project in the repository's type check.
Render errors belong to Vue or the story. Missing injection warnings, undefined props, plugin initialization errors, and failed imports should appear in the browser console or dev-server output. Reproduce the same story directly in the gallery. If it fails there without Playwright, fix the story or application pipeline before changing test timeouts.
Network timing creates another recognizable failure. mount() navigates to the gallery and renders the story, so a component can issue its initial request during that call. Register page.route() before mounting. Registering it afterward misses the request and can make local tests depend on a live backend.
import { expect, test } from '@playwright/test';
test('renders a recoverable inventory error', async ({ page, mount }) => {
await page.route('**/api/inventory', async route => {
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: 'maintenance' }),
});
});
const component = await mount('components/InventoryPanel/Default');
await expect(component.getByRole('alert')).toContainText('Inventory is unavailable');
await expect(component.getByRole('button', { name: 'Retry' })).toBeEnabled();
});If the route never appears in the trace, verify the URL pattern and service-worker setting. If the request is fulfilled with 503 but no alert appears, the gallery and route are working and the defect belongs to component error handling or the story's providers. If a different request fails first, the component has an undocumented dependency that the story must supply.
Trace Viewer is particularly useful here because it connects mount, network activity, browser console, and DOM snapshots. Select the mount action and inspect the gallery URL and error. Select the click and compare before and after snapshots. A screenshot of the final empty root cannot tell you whether story resolution, Vue rendering, or an initial request failed.
A pass on retry can expose leaked context state. Local storage, service workers, permissions, or a route left by another test can alter a reused gallery. Run the case alone with reuse disabled. If that fixes it, audit story teardown and context policy instead of keeping the retry. Isolation failures tend to grow as the suite adds stories.
Migrate in slices and wire one honest CI job
Do not convert an experimental suite in one mechanical change. First add the stable gallery and a new components project while the old CT command still runs. Port one representative component with props, one with emitted state, and one with a provider or network request. Those three reveal most architectural gaps before hundreds of files move.
For each old test, move inline JSX or Vue composition into a named story. Move callbacks into browser-side story state and expose their result through the DOM. Replace experimental mount(Component, options) calls with stable story IDs and serializable props. Replace beforeMount, afterMount, and per-test hooks configuration with gallery setup, story decorators, or props according to ownership.
Keep old and new reports separate during the overlap. A test moved to the gallery should be removed from the experimental project's discovery in the same change, or CI will claim twice the coverage. Track migrated scenarios, not file counts. One old file may contain several compositions that deserve separate story exports.
Once the final test moves, remove the experimental Vue CT dependency and its private cache or template files through the normal dependency change. Delete obsolete ctViteConfig, ctPort, and ctTemplateDir settings only after no old project consumes them. The current Playwright migration guide explicitly marks those concepts as gone in the stable model.
CI should install dependencies, install the browser required by the component project, start the dev server through Playwright configuration, and retain the HTML report or trace on failure. Using npm here avoids any package-manager bootstrap ordering ambiguity.
name: Vue component tests
on:
pull_request:
push:
branches: [main]
jobs:
components:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test --project=components
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: component-playwright-report
path: playwright-report/
retention-days: 7The workflow does not run npm run dev itself because the webServer block owns that process. Starting a second server in YAML can create a port race and hide whether Playwright's readiness configuration works. If a shared preview environment replaces webServer, remove the local server block for that CI project and set the exact gallery URL explicitly.
Keep the first failures visible. A retry can collect a trace, but the report should show that the story needed another attempt. Do not raise retries to compensate for a server that was not ready; webServer.url already defines readiness. Do not increase timeouts to compensate for a missing story ID; no amount of waiting can resolve a name the gallery does not know.
Measure before enabling context reuse or broad browser matrices. Chromium on every pull request gives quick component feedback. Add Firefox or WebKit where engine behavior, layout, or accessibility output creates a real risk. A full cross-browser matrix for every purely presentational story may cost more than it finds. Keep end-to-end coverage responsible for integrated routes and critical browser differences.
Use another test layer when the browser adds no signal
A component test is valuable when browser rendering, focus, accessibility semantics, CSS, events, network behavior, or layout affects the result. A pure formatter, validation function, Pinia getter, or composable with no browser dependency usually belongs in a unit test. Starting Vite and a browser to compare two strings spends more time and gives worse failure localization.
Do not turn the gallery into a miniature duplicate application. A story that installs the full router, authenticates against a live service, loads the production shell, and navigates through several pages is an end-to-end test with extra indirection. Put integrated user journeys in the E2E project. Keep stories small enough that a developer can open one and understand its state.
Client-rendered stories also do not prove server rendering or hydration. A Vue component can look correct when mounted from scratch and still produce a hydration mismatch when server markup differs. Keep a production-build page test for SSR output, hydration warnings, and route-level data loading. Use the gallery to isolate the component after those application boundaries are removed.
The same limit applies to code splitting. Importing a story directly can make its component immediately available, while a real route loads a chunk through the router. Component tests can prove the loaded component behaves correctly. End-to-end tests must prove the chunk URL, deployment headers, error handling, and navigation path work together.
Do not mock every provider until a component can render in a state users never reach. A small fake is useful for a narrow error or loading story, but provider behavior that determines authorization, locale, or routing deserves its own contract. Keep story data explicit and compare it with a real integrated case so isolation does not become fiction.
Visual component tests have a real maintenance cost. Font rendering, operating system, browser version, animations, and global CSS affect screenshots. Use locator assertions for behavior and reserve screenshots for visual contracts worth reviewing. Pin the CI environment and update baselines only after inspecting the rendered difference.
The stable gallery also has a setup cost. The repository owns story discovery, global providers, cleanup, and IDs. That ownership removes bundler drift but adds code the team must review. If a project has three trivial components and no planned component suite, a few focused end-to-end cases plus unit tests may be cheaper than maintaining a gallery.
Do not use component stories to claim native integration coverage. File pickers, operating-system permission dialogs, browser extension behavior, and hardware-backed features cross boundaries the gallery does not reproduce. Test the component's fallback and browser DOM behavior here, then use the appropriate integration layer for the platform behavior.
If the repository cannot upgrade past Playwright 1.61 yet, do not write stable mount examples and hope CI catches up. Keep the experimental suite pinned and documented as temporary, or use ordinary page tests against a hand-built fixture route until the upgrade is possible. The version constraint is a real blocker, not a TypeScript inconvenience.
The gallery makes component internals tempting because they are nearby. Resist that shortcut. Vue refs, emitted event implementation, and private methods can change during a safe refactor. The browser-facing result is the durable contract: a region expands, a button disables, an error is announced, or a route changes. Stories should make those outcomes easy to observe without turning implementation details into public API.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Do Vue component tests still need experimental-ct-vue?
Current Playwright uses the built-in mount fixture from @playwright/test and a framework-owned story gallery. The older experimental package remains a migration concern, not the recommended setup for a new suite.
Which Playwright version adds the stable mount fixture?
The fixture API documents mount as added in Playwright 1.62. Check the installed binary and lockfile before copying current examples into a project pinned to 1.61 or earlier.
Why does a component test say window.mount is not a function?
That message means Playwright reached the configured gallery page but the page did not install the required window.mount contract. Check the gallery entry module and its browser console before changing the component test.
Can Playwright component tests use my existing Vite aliases and CSS?
The story gallery runs through your own dev server, so it uses the same plugins, aliases, transforms, and style entry points you configure for the app. The gallery still needs to import global setup that the component expects.
Should component tests enable reuseContext?
Start with fresh contexts and measure before trading isolation for speed. Reused contexts receive a best-effort reset, and some state such as permissions granted during a test is not reset automatically.
RELATED GUIDES
Continue the learning route
GUIDE 01
Build Cross-Browser Passkey Tests with Playwright
A practical guide to Playwright passkey cross browser testing, with implementation examples, debugging workflows, CI evidence, security controls, and release gates.
GUIDE 02
Playwright Component Testing for React State, Events, and Routing
Test React components in a real browser with Playwright mount fixtures, semantic locators, callback assertions, route hooks, and controlled data.
GUIDE 03
Build a Worker-Scoped Account Pool for Parallel Playwright Tests
Build a worker-scoped Playwright account pool with atomic leases, stable parallel identity, isolated browser contexts, and resilient cleanup.
GUIDE 04
How to Run Tests in Parallel with Playwright
Learn how to run tests in parallel with Playwright: workers, sharding, isolation, CI config, flakiness fixes, and a practical checklist for faster suites.
GUIDE 05
Playwright Tutorial: End-to-End Testing from Scratch
Playwright tutorial for beginners: install, write your first test, TypeScript setup, codegen, fixtures, API testing, debugging, and CI tips in one guide.