Skip to main content
Back to Blog
Guide
2026-08-07

Playwright Component Testing for Vue: A Real-Browser Workflow for QA Engineers

Use this playwright component testing vue guide to test Vue states in real browsers with stories, locators, fixtures, CI evidence, and migration patterns.

Playwright Component Testing for Vue: A Real-Browser Workflow for QA Engineers

Playwright component testing for Vue is most useful when a QA team wants real browser signal without the cost of a full end-to-end journey. You mount one Vue component state, interact with it through Playwright locators, and assert what the user can see or do. That catches layout, focus, event, slot, conditional rendering, and browser behavior that unit tests may not expose.

The current Playwright component testing model is story-gallery based. A component test is still a Playwright test. The component runs in a browser page served by your own dev server, while the test runs in Node.js and uses the built-in mount fixture from @playwright/test. The framework-specific piece is the gallery page and story resolver that your Vue project owns. The official docs describe this model at https://playwright.dev/docs/test-components.

This guide gives QA and test-automation engineers a concrete Vue workflow: decide what belongs in component tests, create stable stories, mount them with Playwright, test props and emitted behavior, wrap providers, mock network boundaries, capture evidence, and avoid the failure mode where the component test is secretly an end-to-end test with a smaller screen. For framework selection context, use JavaScript Testing Frameworks Complete Guide 2026. For locator discipline inside mounted components, use Playwright Best Practices Locators 2026.

Use component tests for dense UI state, not full journeys

Vue component tests earn their keep when one component has many states that are expensive to reach through the full app. A billing card may need free, trial, active, past-due, canceled, loading, retrying, and permission-denied states. Driving the entire app into each state through login, navigation, seeding, and backend calls makes end-to-end tests slow and brittle. Mounting the component directly with controlled props makes those states cheap to cover.

That does not make component testing a replacement for end-to-end testing. Component tests prove the component behaves in isolation with its wrappers and mocks. End-to-end tests prove routes, authentication, backend integration, deployment configuration, and real user journeys. A healthy Vue test strategy uses both.

Risk to testVue component testEnd-to-end Playwright test
Conditional rendering for many statesStrong fitUsually too slow for full matrix
Browser focus and keyboard behaviorStrong fitGood for one critical journey
Route guard and auth redirectPossible with wrapperStrong fit
Backend integration and persistenceMock or fake onlyStrong fit
Visual regression for a componentStrong fitStrong fit for full page
Cross-service checkoutPoor fitStrong fit

The decision is not about which layer is "better." It is about which layer gives the clearest failure. If a button's disabled state is wrong, a component test should fail close to the component. If checkout does not create an order, an end-to-end test should fail at the integration boundary.

Think in stories before thinking in tests

In the Playwright story-gallery model, a story is a named component scenario. It hard-codes the props, slots, mocks, and providers needed to render one state. The test mounts that story and interacts with the returned root locator. This is valuable for QA because the story becomes a shared contract between component authors, test authors, and AI coding agents.

Start with a small Vue component:

<template>
  <section aria-label="Plan summary">
    <h2>{{ planName }}</h2>
    <p v-if="status === 'active'">Your subscription is active.</p>
    <p v-else-if="status === 'past_due'">Update your payment method.</p>
    <p v-else>Choose a plan to continue.</p>

    <button :disabled="status === 'past_due'" @click="$emit('manage')">
      Manage plan
    </button>
  </section>
</template>

<script setup lang="ts">
defineProps<{
  planName: string;
  status: 'active' | 'past_due' | 'none';
}>();

defineEmits<{
  manage: [];
}>();
</script>

Then define story entries that describe meaningful states. The exact story resolver is owned by your gallery setup, but the story module should stay plain and easy to inspect:

import PlanSummary from './PlanSummary.vue';

export const ActiveTeamPlan = {
  component: PlanSummary,
  props: {
    planName: 'Team',
    status: 'active',
  },
};

export const PastDueTeamPlan = {
  component: PlanSummary,
  props: {
    planName: 'Team',
    status: 'past_due',
  },
};

export const NoPlanSelected = {
  component: PlanSummary,
  props: {
    planName: 'No plan',
    status: 'none',
  },
};

A good story is not a test yet. It is a reproducible component state. That distinction matters. Stories can also help designers, developers, and AI agents see the exact state under discussion without navigating through the product.

Configure Playwright around your own Vue dev server

The built-in component testing model expects your own dev server to serve the gallery. For a Vite-powered Vue app, that normally means adding a gallery route or small gallery entry that the existing dev server can load. Playwright points baseURL at that page and the mount fixture drives it.

A typical Playwright config uses webServer to start the app and baseURL to tell tests where the gallery lives:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests/components',
  fullyParallel: true,
  use: {
    baseURL: 'http://127.0.0.1:5173/playwright/gallery/',
    trace: 'on-first-retry',
  },
  webServer: {
    command: 'npm run dev -- --host 127.0.0.1',
    url: 'http://127.0.0.1:5173/playwright/gallery/',
    reuseExistingServer: !process.env.CI,
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
  ],
});

Treat this config as an example of the moving parts, not a universal drop-in file. Your port, dev command, gallery path, aliases, CSS loading, and project matrix may differ. The important point is ownership: Playwright is not compiling Vue for you. Your project serves the component gallery with the same build pipeline that already understands your Vue single-file components, CSS, plugins, and aliases.

Mount Vue stories and query inside the component root

The mount fixture returns a locator for the mounted component root. Scope queries through that locator whenever possible. This prevents a component test from accidentally finding something in the gallery shell, a leftover notification, or a dev overlay.

import { test, expect } from '@playwright/test';

test('active plan allows management', async ({ mount }) => {
  const component = await mount('components/PlanSummary/ActiveTeamPlan');

  await expect(component.getByRole('heading', { name: 'Team' })).toBeVisible();
  await expect(component.getByText('Your subscription is active.')).toBeVisible();
  await expect(component.getByRole('button', { name: 'Manage plan' })).toBeEnabled();
});

test('past due plan blocks management', async ({ mount }) => {
  const component = await mount('components/PlanSummary/PastDueTeamPlan');

  await expect(component.getByText('Update your payment method.')).toBeVisible();
  await expect(component.getByRole('button', { name: 'Manage plan' })).toBeDisabled();
});

Notice the assertions are user-facing. They do not inspect Vue internals, component instance properties, or implementation-specific classes. The browser renders the component, Playwright finds accessible elements, and the test asserts behavior visible to the user.

This pattern also creates better prompts for AI coding agents. If a failure says the Manage plan button is enabled in the PastDueTeamPlan story, the agent has a small, specific component state to inspect. It does not need to understand a full checkout path.

Test props, slots, and events through visible behavior

Vue components often combine props, slots, and emitted events. In component tests, resist the urge to reproduce unit-test internals. Test what changes in the browser. If the component emits an event that the parent would normally handle, create a story that records the callback result in visible text.

<template>
  <div>
    <PlanSummary
      plan-name="Team"
      status="active"
      @manage="managed = true"
    />
    <p data-testid="manage-event">{{ managed ? 'managed' : 'idle' }}</p>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import PlanSummary from './PlanSummary.vue';

const managed = ref(false);
</script>

Then test the event through the wrapper story:

import { test, expect } from '@playwright/test';

test('manage event updates the wrapper state', async ({ mount }) => {
  const component = await mount('components/PlanSummary/ManageEventStory');

  await expect(component.getByTestId('manage-event')).toHaveText('idle');
  await component.getByRole('button', { name: 'Manage plan' }).click();
  await expect(component.getByTestId('manage-event')).toHaveText('managed');
});

This keeps the test in the browser contract. The component emits, the wrapper responds, and the user-visible state changes. You do not need a mock function assertion unless your gallery design exposes callback recording another way.

For slots, build stories that render realistic slot content. Do not pass abstract placeholder text if the component's layout depends on actual copy length, icons, or interactive children.

Wrap Pinia, router, i18n, and design-system providers deliberately

Most real Vue components are not pure prop renderers. They may need Pinia stores, a router instance, i18n messages, injected services, or design-system plugins. Component testing stays reliable when those providers are explicit in the story or gallery layer.

DependencyWhere to provide itTesting note
Pinia storeStory wrapper or gallery app setupSeed state per story, reset between mounts
Vue RouterStory wrapper with memory-style route setupAssert links and route-aware rendering, not full navigation unless needed
i18nGallery app setup or story wrapperTest important locales as separate stories
Design system pluginGallery app setupMatch production registration as closely as practical
API clientMocked service or page routeAvoid real network calls for component state matrix
Feature flagsStory props, store state, or injected serviceMake flags visible in the story name

Here is a Pinia-oriented story wrapper pattern:

<template>
  <PlanLimitNotice />
</template>

<script setup lang="ts">
import { createPinia, setActivePinia } from 'pinia';
import { useAccountStore } from '../../stores/account';
import PlanLimitNotice from './PlanLimitNotice.vue';

const pinia = createPinia();
setActivePinia(pinia);

const account = useAccountStore();
account.plan = 'free';
account.membersUsed = 3;
account.membersLimit = 3;
</script>

The wrapper makes the store state local to the story. The test does not need to know how the store is created. It only verifies what the component shows:

import { test, expect } from '@playwright/test';

test('free plan limit notice explains the member cap', async ({ mount }) => {
  const component = await mount('components/PlanLimitNotice/FreeLimitReached');

  await expect(component.getByText('You have used all 3 team seats.')).toBeVisible();
  await expect(component.getByRole('link', { name: 'Upgrade plan' })).toBeVisible();
});

Provider setup is where component tests often get messy. If every story manually creates the same providers, introduce a small wrapper factory in the gallery code. If every component needs a different store shape, keep it local so the story remains honest.

Mock network at the browser boundary

Component tests should not depend on live APIs for ordinary state coverage. If a component fetches suggestions, validates a field, or loads a preview, mock that network boundary with Playwright routing or inject a fake service through the story. Use real APIs only when the point of the test is integration, and then consider whether the test belongs in the end-to-end suite instead.

import { test, expect } from '@playwright/test';

test('user search displays the empty state', async ({ page, mount }) => {
  await page.route('**/api/users/search**', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ users: [] }),
    });
  });

  const component = await mount('components/UserSearch/Default');

  await component.getByRole('searchbox', { name: 'Search users' }).fill('nobody');
  await expect(component.getByText('No users found')).toBeVisible();
});

The mock belongs near the test when it is part of the scenario. For repeated responses, move it to a helper. Keep mock payloads small and aligned with the API contract. A component test with a fake response that no longer resembles production can give false confidence.

Add visual checks only where pixels carry risk

Playwright component tests can support visual assertions because the component runs in a real browser. Use them selectively. Visual snapshots are valuable for design-system primitives, chart labels, responsive cards, dense tables, and components where CSS regressions are common. They are less valuable for content that changes often or states already covered by strong semantic assertions.

Component typeVisual check valueSemantic check valueRecommendation
Button and form controlsMediumHighUse semantic checks plus a small visual baseline for design system
Pricing cardHighHighUse both for critical layouts
Toast notificationMediumHighPrefer semantic, add visual for animation-free final state
Chart or mapHighMediumUse visual with stable data
User avatar listLow if images varyMediumMock images or avoid visual check

Example:

import { test, expect } from '@playwright/test';

test('pricing card visual state is stable', async ({ mount }) => {
  const component = await mount('components/PricingCard/TeamAnnual');

  await expect(component.getByRole('heading', { name: 'Team' })).toBeVisible();
  await expect(component).toHaveScreenshot('team-annual-pricing-card.png');
});

Keep the component state deterministic before taking a screenshot. Freeze test data, avoid real time, turn off animations when your project normally does that for tests, and use stable fonts in CI. A screenshot that fails every week because the story uses live dates is not useful.

Keep component tests small enough to diagnose

A component test should fail with a narrow explanation. If a test mounts a page shell, real router, live store, real API, and several child workflows, it has become an end-to-end test with a different entry point. That may still be useful, but it should be named and maintained as integration coverage.

Use this scope table:

Test smellWhat it suggestsBetter shape
Test navigates through several routesIt is testing app flowMove to end-to-end or create a router-specific component test
Story calls live backend by defaultState is not deterministicMock API or seed through a controlled fake
One test checks six component statesFailure will be vagueOne story and one test per important state
Assertions use CSS classes onlyTest knows implementation detailsPrefer role, label, text, and stable test IDs
Component root is ignoredTest may query gallery shellScope from the returned locator

This is where many teams get component testing wrong. They adopt it to make tests faster, then rebuild the entire application around the component. The result is neither fast nor clear. The better approach is to make stories small, providers explicit, and assertions user-centered.

Diagnose the blank gallery failure

A realistic Vue component testing failure looks like this: the Playwright test calls mount, the page opens, but the component root stays blank or the mount call fails. The stack trace may point at the gallery rather than the component. Diagnose the pipeline in layers.

First, open the gallery URL in a browser and confirm the dev server serves it. If the page itself does not load, fix webServer, baseURL, or your dev command. Second, confirm the story id passed to mount matches what the gallery resolver exposes. A typo in the story path should fail at mount time. Third, check Vue compile errors in the dev server output. Since your own dev server compiles the component, alias and plugin problems are build problems, not Playwright locator problems. Fourth, check provider setup. A component that expects Pinia, router, or i18n can render blank if the story did not provide the dependency correctly.

Add a tiny smoke story and test to isolate the gallery:

<template>
  <main>
    <h1>Gallery smoke story</h1>
    <button>Ready</button>
  </main>
</template>
import { test, expect } from '@playwright/test';

test('gallery can mount a smoke story', async ({ mount }) => {
  const component = await mount('components/GallerySmoke/Default');

  await expect(component.getByRole('heading', { name: 'Gallery smoke story' })).toBeVisible();
  await expect(component.getByRole('button', { name: 'Ready' })).toBeVisible();
});

If the smoke story works, the gallery pipeline is alive and the failing component likely has a story, provider, alias, or runtime error. If the smoke story fails, fix the gallery before debugging component code.

Make failures useful for AI-assisted repair

AI coding agents perform better when component tests expose small, named states. A failure called past due plan blocks management with a story named PastDueTeamPlan gives the agent a direct path: inspect the component, inspect the story, compare expected disabled behavior. A failure called billing works gives it very little.

Attach concise context for complex stories:

import { test, expect } from '@playwright/test';

test('localized upgrade banner uses German copy', async ({ mount }, testInfo) => {
  const story = {
    id: 'components/UpgradeBanner/GermanFreePlan',
    locale: 'de-DE',
    plan: 'free',
  };

  const component = await mount(story.id);

  await testInfo.attach('component-story-context.json', {
    body: JSON.stringify(story, null, 2),
    contentType: 'application/json',
  });

  await expect(component.getByRole('link', { name: 'Tarif upgraden' })).toBeVisible();
});

The attachment is small, deterministic, and tied to the story. It does not dump the whole page or expose secrets. It gives a human reviewer and an agent enough context to reproduce the state.

Build the Vue component test outline deliberately

For a new Vue component, define the test outline before writing code. This keeps coverage focused and prevents the matrix from exploding.

Coverage areaExample for a Vue billing bannerTest layer
Default renderFree plan shows upgrade promptComponent
Important variantsTrial, active, past due, canceledComponent
User interactionClicking manage emits or changes wrapper stateComponent
Provider behaviorStore state controls banner copyComponent
Route integrationBilling page displays the banner for real accountEnd-to-end
Backend updatePayment status changes after provider eventAPI or end-to-end

This outline also clarifies ownership. Component tests can be written before the full route is finished, as long as the component and story contract exist. End-to-end tests can stay lean and cover only the route-level confidence.

Cover keyboard and accessibility states at the component layer

Vue component tests are a strong place to catch accessibility regressions because the component is small enough to inspect and the browser behavior is real. Focus order, button disabled state, labels, accessible names, and keyboard activation can all be tested without logging into the full application. This is especially valuable for design-system components that appear on many pages.

Start with the component's public contract. A combobox story should prove the input has the right accessible name, arrow keys expose options, escape closes the list, and selection updates visible text. A modal story should prove initial focus, close behavior, and disabled background interactions where your component supports them. A form-field story should prove label association, error announcement, and disabled state.

Component patternAccessibility behavior to testUseful locator
Modal dialogDialog name, initial focus, close actiongetByRole('dialog')
ComboboxLabel, option visibility, keyboard selectiongetByRole('combobox')
ToastStatus or alert text appearsgetByText or role-based query when appropriate
Tab listSelected tab and panel contentgetByRole('tab')
Form fieldLabel, error text, disabled stategetByLabel and visible error text

Example keyboard test:

import { test, expect } from '@playwright/test';

test('country combobox supports keyboard selection', async ({ mount }) => {
  const component = await mount('components/CountryCombobox/Default');
  const combobox = component.getByRole('combobox', { name: 'Country' });

  await combobox.focus();
  await combobox.press('ArrowDown');
  await expect(component.getByRole('option', { name: 'Canada' })).toBeVisible();
  await combobox.press('Enter');

  await expect(combobox).toHaveValue('Canada');
});

This test would be noisy as an end-to-end scenario if the only goal is combobox behavior. As a component test, it is direct and fast. It also catches regressions that a plain snapshot would miss.

Do not confuse accessible queries with a complete accessibility audit. Role and label assertions are strong signals, but they do not replace automated scans, manual keyboard review, or assistive technology checks for critical flows. The component layer is where you prevent obvious regressions from spreading.

Migrate older Vue component tests one slice at a time

Many Vue projects adopted an older Playwright component testing setup or a different component runner before the story-gallery model became the documented path. Migration should be incremental. Do not stop the whole QA program to rewrite every component test at once. Choose one component family, create the gallery pattern, prove it in CI, then convert the next slice.

Use this migration map:

Existing patternMigration moveRisk to watch
Direct component mount through an older packageCreate equivalent story exports and mount story idsEvent assertion style may need wrapper stories
Storybook-only browser testsReuse story concepts, but keep Playwright gallery deterministicStorybook decorators may hide dependencies
Vue Test Utils unit testsKeep logic tests, add Playwright only for browser behaviorDuplicating low-value assertions
End-to-end tests for every UI stateMove dense state matrix to storiesLosing route-level integration coverage
Manual QA state checklistConvert stable states to storiesOver-automating states that change weekly

A good first migration target has several visible states, few provider dependencies, and known UI risk. Bad first targets include components with live websockets, complicated app boot, or unclear ownership. Migration should prove the pattern, not stress every edge on day one.

When converting an old test, preserve the assertion intent before changing the mechanics. If the old test proved "past due accounts cannot manage plan," the new story and Playwright test should still prove that behavior. Do not blindly copy implementation selectors or internal state checks into the new layer.

Keep the gallery boring and observable

The gallery is infrastructure. It should be small, deterministic, and easy to debug. Avoid turning it into a product surface with navigation, themes, analytics, or stateful controls unless those pieces are required to mount stories correctly. Every extra behavior in the gallery is another possible reason a component test can fail before the component even renders.

Make the gallery observable enough for failure diagnosis. If an unknown story id is requested, fail with a clear error. If a story throws during render, let the error surface instead of swallowing it and leaving a blank root. If providers are missing, prefer a visible error during story setup over a component that silently renders nothing.

type MountParams = {
  story: string;
  props?: Record<string, unknown>;
};

declare global {
  interface Window {
    mount(params: MountParams): Promise<void>;
    unmount(): Promise<void>;
  }
}

window.mount = async (params: MountParams) => {
  if (!params.story) {
    throw new Error('Story id is required');
  }

  await renderStoryIntoRoot(params.story, params.props || {});
};

window.unmount = async () => {
  await clearRoot();
};

The example leaves renderStoryIntoRoot and clearRoot as gallery-owned functions because each Vue project resolves stories differently. The important contract is explicit: Playwright calls window.mount, the gallery renders into the root, and errors reject the mount call.

Decide when not to use Playwright component testing

Playwright component testing is powerful, but using it for every assertion can slow the team down. Keep pure formatting functions, composables with no browser behavior, and simple prop transformations in unit tests. Keep full auth flows, persistence, and deployment smoke checks in end-to-end tests. Use component tests for the middle layer where browser-rendered UI state matters.

Work itemBetter layerReason
Currency formatting utilityUnit testNo browser behavior
Vue composable that calculates limitsUnit testFast logic feedback
Dropdown keyboard behaviorComponent testReal focus and events matter
Billing page route after loginEnd-to-endAuth, route, and backend integration matter
Provider webhook changes account stateAPI or end-to-endServer behavior is the risk
Design-system button variantsComponent testVisual and accessible states are user-visible

This restraint keeps component testing valuable. A QA suite with fewer, sharper component tests is easier to maintain than a suite that mounts every component for trivial assertions. The goal is not maximum count. The goal is fast, clear signal at the right layer.

Frequently Asked Questions

Is Playwright component testing for Vue a replacement for Vue Test Utils?

No. Vue Test Utils is still useful for fast unit-level checks, emitted event assertions, and component internals in a simulated DOM environment. Playwright component testing is useful when real browser behavior matters: layout, focus, keyboard interaction, accessible queries, visual snapshots, and cross-browser rendering. Many teams use both: Vue Test Utils for pure component logic and Playwright for browser-visible behavior.

Do I need the old experimental Vue component testing package?

For the current story-gallery model, tests import from @playwright/test and use the built-in mount fixture. The official Playwright docs say this model replaces the older experimental React and Vue component testing packages. If your project already uses an older package, treat migration as a planned test-infrastructure change: read the Playwright docs, update the gallery model, and verify CI before broad conversion.

Should Vue component tests call real APIs?

Usually no. Component tests should keep state deterministic with props, stores, injected fakes, or Playwright route mocks. Real APIs introduce authentication, network, data cleanup, and environment failures that belong in end-to-end or API tests. Use a real API only when the integration itself is the subject, and then ask whether a full Playwright journey would communicate the risk more clearly.

How many stories should a complex Vue component have?

Create one story for each meaningful user-visible state, not for every possible prop combination. A billing component may need free, trial, active, past-due, and canceled stories. It probably does not need every color, copy, and account-size permutation. Use risk to choose states: permissions, disabled controls, error messages, empty data, and localization usually deserve explicit stories.