18 — Testing
Component Mount — Vue Test Utils
UserCard.spec.ts
import { mount, type VueWrapper } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import UserCard from './UserCard.vue'
// ── mount: full render (includes child components) ──
// shallowMount: stubs all child components (faster, tests in isolation)
describe('UserCard', () => {
let wrapper: VueWrapper
beforeEach(() => {
// ── mount with props, slots, and global config ──
wrapper = mount(UserCard, {
props: {
user: { id: 1, name: 'Ada', role: 'admin' },
editable: true,
},
// ── slots: test slot content ──
slots: {
default: '<p>Custom content</p>',
header: '<h2>Header</h2>',
},
// ── global: plugins, stubs, mocks ──
global: {
// ── stubs: replace specific components with simple versions ──
stubs: {
RouterLink: true, // auto-stub (renders a <stub>)
HeavyChart: '<div />', // custom stub (renders a div)
},
// ── mocks: inject mock provide values ──
provide: {
apiClient: { get: vi.fn().mockResolvedValue({ data: 'mocked' }) },
},
// ── plugins: register Pinia, Router, etc. ──
plugins: [createTestingPinia()],
},
})
})
it('renders user name', () => {
expect(wrapper.text()).toContain('Ada')
})
it('emits delete with user id on click', async () => {
// ── find by component, text, selector, or test id ──
const button = wrapper.find('[data-testid="delete-btn"]')
await button.trigger('click') // trigger DOM event
// ── Assert emitted event ──
expect(wrapper.emitted('delete')).toBeTruthy()
expect(wrapper.emitted('delete')![0]).toEqual([1]) // first emit, first arg = 1
})
it('updates input value on v-model', async () => {
const input = wrapper.find('input')
await input.setValue('Grace')
// setValue: sets value + triggers 'input' event (v-model compatible)
expect(wrapper.emitted('update:modelValue')![0]).toEqual(['Grace'])
})
})
Testing Composables — effectScope
useCounter.spec.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { effectScope, nextTick } from 'vue'
import { useCounter } from './useCounter'
// ── Composables with effects/watchers need effectScope for cleanup ──
// Without scope: effects created in the composable persist after the test,
// potentially affecting other tests (cross-test contamination).
describe('useCounter', () => {
let scope: ReturnType<typeof effectScope>
beforeEach(() => {
scope = effectScope() // fresh scope per test — auto-disposes effects
})
afterEach(() => {
scope.stop() // disposes all effects created in the scope
})
it('starts at 0', () => {
const { count } = scope.run(() => useCounter())!
expect(count.value).toBe(0)
})
it('increments', () => {
const { count, increment } = scope.run(() => useCounter())!
increment()
expect(count.value).toBe(1)
})
it('watches count and calls callback on change', async () => {
const { count, increment } = scope.run(() => useCounter())!
const cb = vi.fn()
scope.run(() => {
watch(count, cb) // watcher registered inside the scope
})
increment()
await nextTick() // wait for watcher to fire (async)
expect(cb).toHaveBeenCalledWith(1, 0)
})
})
Testing Pinia Stores
userStore.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useUserStore } from './userStore'
import { api } from './api'
// ── createTestingPinia: auto-mocks all actions (no side effects) ──
// Or: setActivePinia(createPinia()) for real store logic with mocked deps.
describe('UserStore', () => {
beforeEach(() => {
setActivePinia(createPinia()) // fresh Pinia per test — no state leakage
})
it('login sets user and token', async () => {
const store = useUserStore()
vi.spyOn(api, 'login').mockResolvedValue({
user: { id: 1, name: 'Ada' },
token: 'tok123',
})
await store.login({ email: 'ada@example.com', password: 'secret' })
expect(store.user).toEqual({ id: 1, name: 'Ada' })
expect(store.token).toBe('tok123')
expect(api.login).toHaveBeenCalledWith({ email: 'ada@example.com', password: 'secret' })
})
it('isAuthenticated returns true when token exists', () => {
const store = useUserStore()
expect(store.isAuthenticated).toBe(false)
store.token = 'tok'
expect(store.isAuthenticated).toBe(true)
})
it('$reset restores initial state', () => {
const store = useUserStore()
store.user = { id: 1, name: 'Ada' }
store.$reset() // only works on Options API stores
expect(store.user).toBe(null)
})
})
Async Testing — waitFor and Flush
async-spec.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import { flushPromises, nextTick } from '@vue/test-utils'
import AsyncComponent from './AsyncComponent.vue'
describe('AsyncComponent', () => {
it('renders data after fetch resolves', async () => {
// ── Mock the global fetch ──
const mockData = { name: 'Ada' }
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockData),
} as Response)
const wrapper = mount(AsyncComponent)
// ── Loading state should show immediately ──
expect(wrapper.text()).toContain('Loading')
// ── flushPromises: resolves all pending microtasks (promises) ──
await flushPromises()
// ── After resolution: data should be rendered ──
expect(wrapper.text()).toContain('Ada')
expect(fetch).toHaveBeenCalledWith('/api/data')
})
it('shows error on fetch failure', async () => {
vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error'))
const wrapper = mount(AsyncComponent)
await flushPromises()
expect(wrapper.text()).toContain('Network error')
})
it('watches and re-fetches on prop change', async () => {
const wrapper = mount(AsyncComponent, { props: { userId: 1 } })
await flushPromises()
// Clear previous calls
vi.mocked(fetch).mockClear()
// Change prop → triggers watcher → new fetch
await wrapper.setProps({ userId: 2 })
await flushPromises()
expect(fetch).toHaveBeenCalledWith('/api/users/2')
})
it('handles nextTick for DOM updates', async () => {
const wrapper = mount(AsyncComponent)
await wrapper.find('button').trigger('click')
// DOM not yet updated — need nextTick
await nextTick()
expect(wrapper.find('.result').exists()).toBe(true)
})
})
💡 Tips & Tricks
tips.ts
// ── 1. Use data-testid for test selectors (not CSS classes) ──
// <button data-testid="submit">Submit</button>
// wrapper.find('[data-testid="submit"]')
// CSS classes change for styling reasons; test ids are stable.
// ── 2. Mock modules at the top of the file ──
vi.mock('@/api/client', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Ada' }),
updateUser: vi.fn(),
}))
// ── 3. Test emitted events with wrapper.emitted() ──
// wrapper.emitted('event-name') → array of arg arrays: [[arg1, arg2], [arg1]]
// wrapper.emitted() → all events as object: { 'event-name': [...] }
// ── 4. Snapshot testing for component output ──
expect(wrapper.html()).toMatchSnapshot()
// Use only for stable components — snapshots break easily and are noisy.
// ── 5. Testing transitions: disable them ──
mount(Comp, { global: { stubs: ['Transition', 'TransitionGroup'] } })
// Transitions are async — stubbing them makes tests deterministic.
⚠️ Edge Cases & Gotchas
edge-cases.ts
// ── 1. mount vs shallowMount: child behavior not tested with shallow ──
// shallowMount stubs ALL children — if a bug is in a child, you miss it.
// Use mount for integration tests, shallowMount for isolated unit tests.
// ── 2. flushPromises is NOT a real Vue tick — it only flushes microtasks ──
// For DOM updates, use nextTick() (Vue's reactivity flush queue).
// flushPromises + nextTick may both be needed in async tests.
// ── 3. vi.fn() mocks persist across tests unless cleared ──
// vi.clearAllMocks() in beforeEach to reset call counts and implementations.
// vi.restoreAllMocks() also restores the original implementation.
// ── 4. Testing v-model: setValue triggers 'input', not 'change' ──
// For .lazy (which uses 'change'), use: input.element.value = 'x'; input.trigger('change')
// ── 5. Timing: watch/watchEffect fire asynchronously (pre/post flush) ──
// After triggering an event, await nextTick() before asserting watcher side effects.
// ── 6. Testing Pinia getters with parameters ──
// Getters that return functions (itemPrice(id)) must be called with a mock id.
// store.itemPrice(1) — the getter is called with the id, not memoized.
🧠 Spot the Bug
A test asserts on emitted events but gets undefined.
TestBug.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import Counter from './Counter.vue'
describe('Counter', () => {
it('emits increment', () => {
const wrapper = mount(Counter)
wrapper.find('button').trigger('click') // ❌ missing await
expect(wrapper.emitted('increment')).toBeTruthy() // undefined — event hasn't fired yet
})
})
Answer
trigger('click') returns a promise — the click handler (which calls emit) runs asynchronously. Without await, the assertion executes before the handler completes, so emitted('increment') is undefined.
Fix — await the trigger:
TestFixed.ts
it('emits increment', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click') // ✅ await the event
expect(wrapper.emitted('increment')).toBeTruthy()
})
The lesson: all DOM interactions in Vue Test Utils are async. trigger(), setValue(), and setProps() return promises that must be awaited before asserting on the result.