Testing: Vitest and Vue Test Utils
A test is code that checks by itself that a component works as intended: shows the right text, emits an event on a click. Vitest is a test runner made for Vite projects. Vue Test Utils is the official library that mounts a component in a test and lets you click it and read its markup.
Installing
In the todo-app project run:
npm install -D vitest @vue/test-utils jsdomjsdom imitates a browser inside Node.js — components need a DOM. Set it in vite.config.js, in the config object next to plugins:
// vite.config.js
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
test: {
environment: 'jsdom',
},
})And add a command to the scripts section of package.json: "test:unit": "vitest". If you tick Vitest when creating a project, create-vue does all this itself.
The first test
Tests go into a __tests__ folder next to the components. Create src/components/__tests__/TodoItem.spec.js for the TodoItem component from the events lesson:
// src/components/__tests__/TodoItem.spec.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import TodoItem from '../TodoItem.vue'
describe('TodoItem', () => {
it('shows the task text', () => {
const wrapper = mount(TodoItem, {
props: { todo: { id: 1, text: 'Buy bread', done: false } },
})
expect(wrapper.text()).toContain('Buy bread')
})
})Run it:
npm run test:unitThe terminal prints a line with a tick: ✓ src/components/__tests__/TodoItem.spec.js (1 test). mount creates the component with the given props, wrapper.text() returns all its text. Vitest keeps watching the files and reruns the tests on save.
Clicks and events
// src/components/__tests__/TodoItem.spec.js
it('reports a delete', async () => {
const wrapper = mount(TodoItem, {
props: { todo: { id: 1, text: 'Buy bread', done: false } },
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('remove')).toHaveLength(1)
})find looks up an element by CSS selector, trigger fires an event. await lets Vue update the markup. emitted('remove') returns the list of all calls of the event.
| Method | What it does |
|---|---|
wrapper.find('selector') | Finds an element |
wrapper.text(), wrapper.html() | Text and markup |
await el.trigger('click') | Fires an event |
await el.setValue('text') | Types a value into a field |
wrapper.emitted('name') | Emitted events and their arguments |
Testing a store
A Pinia store is tested without components — it is enough to create an active Pinia instance:
// src/stores/__tests__/posts.spec.js
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { usePostsStore } from '../posts.js'
describe('posts store', () => {
beforeEach(() => setActivePinia(createPinia()))
it('adds a post to the top', () => {
const store = usePostsStore()
store.addPost('New', 'Text')
expect(store.count).toBe(1)
expect(store.posts[0].title).toBe('New')
})
})This test belongs to the blog project: Vitest is installed there the same way.
Common mistake
Checking the result without await after trigger or setValue. Vue updates the DOM not immediately but on the next tick, so the test reads the old markup — it fails although the component works. Always write await before these methods and declare the test function async.