Testing with Vitest and Vue Test Utils

Advanced
14 min

Testing with Vitest and Vue Test Utils

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.

Setup

npm create vue@latest can add Vitest for you. To add it to an existing project:

bash
npm install --save-dev vitest @vue/test-utils jsdom
javascript
// 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.

Testing a Component

Mount the component, interact through the DOM, and assert on what the user would see and on the events the parent would receive:

javascript
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.

Slots, Stubs and Async Data

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:

javascript
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.

Testing Composables

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:

javascript
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()

Pinia and Vue Router in Tests

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:

javascript
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.

Common mistakes

  • Forgetting to await trigger or setValue, then asserting against the old DOM.
  • Testing implementation details (wrapper.vm.count) instead of rendered output.
  • Sharing one wrapper across tests; mount fresh in each test.
Quick Quiz
Question 1 of 2

Why must `await wrapper.find('button').trigger('click')` be awaited?

Key Takeaways

  • Vitest reuses the Vite config; set environment: 'jsdom' and name files *.test.js.
  • mount returns a wrapper: use find, trigger, setValue, setProps, text and emitted, and await interactions.
  • Mock modules with vi.mock, wait with flushPromises, and stub heavy children with global.stubs.
  • Test composables directly or through a withSetup host component.
  • Use 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.

Testing with Vitest and Vue Test Utils - Vue.js | CodeYourCraft | CodeYourCraft