Vue Testing
Vue 3 component testing with Vitest and Vue Test Utils covering unit tests, integration tests, and async patterns
You are an expert in testing Vue 3 components using Vitest and Vue Test Utils, covering unit tests, integration tests, mocking, and async testing patterns. ## Key Points - **`mount` vs `shallowMount`** — `mount` renders the full component tree; `shallowMount` stubs child components - **Wrapper API** — the object returned by `mount` provides methods to query, interact, and assert - **`flushPromises`** — awaits all pending promise resolutions to test async behavior - **`vi.mock`** — Vitest's module mocking for isolating dependencies - **Component stubs and global config** — provide mocks, plugins, and stubs via mount options 1. **Use `data-testid` attributes** — query by test IDs rather than CSS classes or element structure, which change frequently. 2. **Prefer `mount` over `shallowMount`** — shallow mounting hides real integration issues. Use `mount` by default and only shallow-mount when child components are expensive or irrelevant. 3. **Test behavior, not implementation** — assert on rendered output, emitted events, and user-visible state rather than internal component data. 4. **Use `flushPromises` for async** — after any action that triggers async updates (fetch, nextTick), await `flushPromises()` before assertions. 5. **Use `createTestingPinia`** — it provides a real Pinia instance with pre-set state and optionally stubbed actions, making store-dependent tests clean. 6. **Keep tests fast** — avoid real network calls, use `vi.mock` or `vi.fn` for external dependencies. 7. **Test composables independently** — composables without lifecycle hooks can be tested as plain functions. Use the `withSetup` helper for those that need a component context.
skilldb get vue-skills/Vue TestingFull skill: 367 linesVue Component Testing — Vue.js
You are an expert in testing Vue 3 components using Vitest and Vue Test Utils, covering unit tests, integration tests, mocking, and async testing patterns.
Core Philosophy
Testing Vue components well requires a mindset shift: you are testing behavior, not implementation. The consumer of a component is the user who sees rendered output, clicks buttons, and reads text — not a developer who inspects internal data properties. Every test should be expressible as "when the user does X, they should see Y" or "when X happens, event Y is emitted." If a test can only be expressed in terms of internal component state, it is testing the wrong thing.
The testing pyramid applies to Vue applications with a specific twist. Composables and stores can be unit-tested as pure functions with fast, isolated tests. Components should be integration-tested with mount (not shallowMount by default) because the real value is verifying that the template, bindings, and child components work together. End-to-end tests cover critical user flows that span multiple pages. Over-investing in shallow-mount unit tests gives false confidence because they hide template binding bugs.
Speed is a feature of a test suite. Vitest's Vite-native architecture makes tests fast enough to run on every save, which changes developer behavior — tests become a development tool rather than a CI gate. The faster the feedback loop, the more developers write tests. Mock external dependencies aggressively (HTTP, timers, third-party services) but resist mocking Vue internals and child components unless there is a specific reason.
Anti-Patterns
-
Testing Internal Component State — accessing
wrapper.vm.someDatato assert on internal state instead of asserting on what the DOM renders. This couples tests to implementation and misses template binding bugs entirely. -
Overusing
shallowMount— stubbing all child components by default, which hides integration bugs between parent and child. Usemountas the default and only reach forshallowMountwhen child components are expensive or genuinely irrelevant. -
Missing
awaiton Trigger Calls — writingwrapper.find('button').trigger('click')withoutawait, then immediately asserting on DOM state that has not updated yet. Vue updates the DOM asynchronously after user interactions. -
Snapshot Testing Dynamic Content — using snapshot tests for components with dynamic data, timestamps, or generated IDs. These snapshots break on every run and train developers to blindly update them, defeating their purpose.
-
Mocking Everything — replacing every dependency with a mock until the test only verifies that mock wiring works correctly. If a test mocks the store, the router, the HTTP client, and all child components, it tests nothing meaningful about the component's actual behavior.
Overview
Vue component testing combines Vitest (a Vite-native test runner) with Vue Test Utils (the official testing library for Vue). Together they enable fast, reliable tests for components, composables, and stores. Tests mount components in isolation, simulate user interactions, and assert on rendered output and emitted events.
Core Concepts
mountvsshallowMount—mountrenders the full component tree;shallowMountstubs child components- Wrapper API — the object returned by
mountprovides methods to query, interact, and assert flushPromises— awaits all pending promise resolutions to test async behaviorvi.mock— Vitest's module mocking for isolating dependencies- Component stubs and global config — provide mocks, plugins, and stubs via mount options
Implementation Patterns
Basic Component Test
// components/__tests__/Counter.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from '../Counter.vue'
describe('Counter', () => {
it('renders initial count', () => {
const wrapper = mount(Counter, {
props: { initial: 5 },
})
expect(wrapper.text()).toContain('5')
})
it('increments count on button click', async () => {
const wrapper = mount(Counter)
const button = wrapper.find('[data-testid="increment"]')
await button.trigger('click')
expect(wrapper.text()).toContain('1')
})
it('emits update event', async () => {
const wrapper = mount(Counter)
await wrapper.find('[data-testid="increment"]').trigger('click')
expect(wrapper.emitted('update')).toHaveLength(1)
expect(wrapper.emitted('update')![0]).toEqual([1])
})
})
Testing with Props and Slots
import { mount } from '@vue/test-utils'
import Alert from '../Alert.vue'
it('renders slot content with correct variant class', () => {
const wrapper = mount(Alert, {
props: { variant: 'error' },
slots: {
default: 'Something went wrong',
icon: '<span class="icon">!</span>',
},
})
expect(wrapper.text()).toContain('Something went wrong')
expect(wrapper.classes()).toContain('alert-error')
expect(wrapper.find('.icon').exists()).toBe(true)
})
Testing Async Components
import { mount, flushPromises } from '@vue/test-utils'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import UserProfile from '../UserProfile.vue'
// Mock the fetch API
global.fetch = vi.fn()
describe('UserProfile', () => {
beforeEach(() => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ name: 'Alice', email: 'alice@example.com' }),
} as Response)
})
it('shows loading then user data', async () => {
const wrapper = mount(UserProfile, {
props: { userId: '1' },
})
expect(wrapper.find('[data-testid="loading"]').exists()).toBe(true)
await flushPromises()
expect(wrapper.find('[data-testid="loading"]').exists()).toBe(false)
expect(wrapper.text()).toContain('Alice')
expect(wrapper.text()).toContain('alice@example.com')
})
it('shows error message on fetch failure', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error'))
const wrapper = mount(UserProfile, {
props: { userId: '1' },
})
await flushPromises()
expect(wrapper.find('[data-testid="error"]').text()).toContain('Network error')
})
})
Testing with Pinia
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
import CartSummary from '../CartSummary.vue'
import { useCartStore } from '@/stores/useCartStore'
it('displays cart total from store', () => {
const wrapper = mount(CartSummary, {
global: {
plugins: [
createTestingPinia({
initialState: {
cart: {
items: [
{ id: '1', name: 'Widget', price: 9.99, quantity: 2 },
{ id: '2', name: 'Gadget', price: 19.99, quantity: 1 },
],
},
},
// Stub all actions by default
stubActions: false,
}),
],
},
})
expect(wrapper.text()).toContain('$39.97')
})
it('calls removeItem when remove button is clicked', async () => {
const wrapper = mount(CartSummary, {
global: {
plugins: [
createTestingPinia({
initialState: {
cart: {
items: [{ id: '1', name: 'Widget', price: 9.99, quantity: 1 }],
},
},
}),
],
},
})
const store = useCartStore()
await wrapper.find('[data-testid="remove-1"]').trigger('click')
expect(store.removeItem).toHaveBeenCalledWith('1')
})
Testing Vue Router Integration
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import App from '../App.vue'
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
{ path: '/about', component: { template: '<div>About</div>' } },
],
})
it('navigates to about page', async () => {
router.push('/')
await router.isReady()
const wrapper = mount(App, {
global: { plugins: [router] },
})
await wrapper.find('a[href="/about"]').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('About')
})
Testing Composables
// composables/__tests__/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from '../useCounter'
describe('useCounter', () => {
it('starts at initial value', () => {
const { count } = useCounter(10)
expect(count.value).toBe(10)
})
it('increments and decrements', () => {
const { count, increment, decrement } = useCounter()
expect(count.value).toBe(0)
increment()
expect(count.value).toBe(1)
decrement()
expect(count.value).toBe(0)
})
})
// For composables that require a component context (lifecycle hooks):
import { withSetup } from '@/test-utils'
function withSetup<T>(composable: () => T): [T, any] {
let result!: T
const wrapper = mount({
setup() {
result = composable()
return () => null
},
})
return [result, wrapper]
}
it('composable with lifecycle hooks', () => {
const [result, wrapper] = withSetup(() => useMouse())
expect(result.x.value).toBe(0)
wrapper.unmount()
})
Testing Form Interactions
it('submits form with input values', async () => {
const wrapper = mount(LoginForm)
await wrapper.find('input[name="email"]').setValue('user@example.com')
await wrapper.find('input[name="password"]').setValue('secret')
await wrapper.find('form').trigger('submit.prevent')
expect(wrapper.emitted('submit')![0]).toEqual([
{ email: 'user@example.com', password: 'secret' },
])
})
it('shows validation errors', async () => {
const wrapper = mount(LoginForm)
// Submit empty form
await wrapper.find('form').trigger('submit.prevent')
expect(wrapper.find('[data-testid="email-error"]').text())
.toBe('Email is required')
})
Vitest Configuration for Vue
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./test/setup.ts'],
include: ['**/__tests__/**/*.test.ts', '**/*.spec.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.{ts,vue}'],
exclude: ['src/**/*.d.ts', 'src/**/__tests__/**'],
},
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
})
// test/setup.ts
import { config } from '@vue/test-utils'
// Global stubs for common components
config.global.stubs = {
// Stub router-link to avoid needing router in every test
RouterLink: {
template: '<a :href="to"><slot /></a>',
props: ['to'],
},
}
Best Practices
- Use
data-testidattributes — query by test IDs rather than CSS classes or element structure, which change frequently. - Prefer
mountovershallowMount— shallow mounting hides real integration issues. Usemountby default and only shallow-mount when child components are expensive or irrelevant. - Test behavior, not implementation — assert on rendered output, emitted events, and user-visible state rather than internal component data.
- Use
flushPromisesfor async — after any action that triggers async updates (fetch, nextTick), awaitflushPromises()before assertions. - Use
createTestingPinia— it provides a real Pinia instance with pre-set state and optionally stubbed actions, making store-dependent tests clean. - Keep tests fast — avoid real network calls, use
vi.mockorvi.fnfor external dependencies. - Test composables independently — composables without lifecycle hooks can be tested as plain functions. Use the
withSetuphelper for those that need a component context.
Common Pitfalls
- Forgetting
awaiton trigger/setValue — Vue updates the DOM asynchronously. Withoutawait, assertions run before the DOM updates. - Testing implementation details — accessing
wrapper.vm.internalDatacouples tests to implementation. Preferwrapper.text(),wrapper.find(), andwrapper.emitted(). - Not cleaning up — if tests modify global state (stores, mocks), use
beforeEach/afterEachto reset.createTestingPiniacreates a fresh store per test. - Mocking too much — over-mocking gives false confidence. If a test mocks everything the component depends on, it tests nothing.
- Missing
global.plugins— components that use router, Pinia, or i18n will throw without providing those plugins in mount options. - Snapshot overuse — snapshot tests are brittle for component markup. Use them sparingly for stable output; prefer targeted assertions for dynamic content.
Install this skill directly: skilldb add vue-skills
Related Skills
Composables
Building reusable custom composables in Vue 3 with proper typing, lifecycle management, and state sharing
Composition API
Patterns and best practices for Vue 3 Composition API including refs, reactive, computed, watchers, and lifecycle hooks
Nuxt Routing
Nuxt 3 file-based routing, dynamic routes, layouts, middleware, and navigation patterns
Nuxt Server
Nuxt 3 server routes, API endpoints, server middleware, and Nitro engine patterns for full-stack development
Pinia
Pinia state management for Vue 3 including store design, actions, getters, plugins, and SSR hydration
Provide Inject
Vue 3 provide/inject for dependency injection, deeply nested component communication, and plugin design