Tests let you refactor a component or a store without clicking through the whole application afterwards. Vitest runs tests with the same Vite configuration as your app, so .vue files and aliases just work, and Vue Test Utils mounts components in a simulated DOM. After this lesson you will test components through props, events and rendered output, test composables in isolation, and mount components that depend on Pinia and Vue Router.
npm create vue@latest can add Vitest for you. To add it to an existing project:
npm install --save-dev vitest @vue/test-utils jsdom// vitest.config.js
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true
}
})Add "test": "vitest" to package.json; vitest run executes once for CI. Test files are named *.test.js and can live next to the component they test.
Mount the component, interact through the DOM, and assert on what the user would see and on the events the parent would receive:
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
describe('Counter', () => {
it('increments and emits the new value', async () => {
const wrapper = mount(Counter, { props: { start: 2 } })
expect(wrapper.text()).toContain('Count: 2')
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('Count: 3')
expect(wrapper.emitted('change')[0]).toEqual([3])
})
})trigger, setValue and setProps return promises that resolve after Vue has re-rendered, which is why they are awaited. Select elements with data-test attributes so styling changes do not break tests. Other useful wrapper methods are findAll, findComponent, exists, isVisible and html. Assert on what the user observes (text, attributes, emitted events) rather than on internal state through wrapper.vm.
mount accepts slots to test content distribution and global.stubs to replace heavy children. For components that fetch on mount, mock the module and wait for pending promises:
import { vi, it, expect } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import UserCard from './UserCard.vue'
import { api } from '@/api/client'
vi.mock('@/api/client', () => ({
api: { get: vi.fn() }
}))
it('shows the loaded user', async () => {
api.get.mockResolvedValue({ data: { name: 'Ada Lovelace' } })
const wrapper = mount(UserCard, {
props: { id: 1 },
slots: { footer: '<span data-test="footer">Extra</span>' },
global: { stubs: { HeavyChart: true } }
})
expect(wrapper.text()).toContain('Loading')
await flushPromises()
expect(wrapper.text()).toContain('Ada Lovelace')
expect(api.get).toHaveBeenCalledWith('/users/1')
})flushPromises resolves pending promises so the component moves from loading to loaded. vi.fn, vi.spyOn and vi.mock are Vitest's mocking tools; vi.useFakeTimers() controls debounce timers.
A composable without lifecycle hooks is a plain function: call it and assert on the returned refs. One that uses onMounted or inject needs a host component; a small helper provides one:
import { createApp } from 'vue'
export function withSetup(composable) {
let result
const app = createApp({
setup() {
result = composable()
return () => {}
}
})
app.mount(document.createElement('div'))
return [result, app]
}
// usage
const [state, app] = withSetup(() => useMouse())
expect(state.x.value).toBe(0)
app.unmount()Install a testing Pinia so stores exist and actions are stubbed by default, and a real router with memory history so RouterLink and useRoute work:
import { createTestingPinia } from '@pinia/testing'
import { createRouter, createMemoryHistory } from 'vue-router'
const router = createRouter({ history: createMemoryHistory(), routes })
await router.push('/orders/7')
const wrapper = mount(OrderView, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn, initialState: { cart: { items: [] } } }), router]
}
})Install @pinia/testing first. Store actions become spies you can assert on; pass stubActions: false to run the real ones. For end-to-end flows in a real browser, add Playwright or Cypress on top of these unit tests.
await trigger or setValue, then asserting against the old DOM.wrapper.vm.count) instead of rendered output.Why must `await wrapper.find('button').trigger('click')` be awaited?
environment: 'jsdom' and name files *.test.js.mount returns a wrapper: use find, trigger, setValue, setProps, text and emitted, and await interactions.vi.mock, wait with flushPromises, and stub heavy children with global.stubs.withSetup host component.createTestingPinia and a memory-history router for components that depend on them.Next lesson: Performance Optimization: v-memo, Virtual Lists and Code Splitting — find and fix slow renders and large bundles.