Vue.js 69 🟢 Unit Testing Vue Components with Vitest and Vue Test Utils
A Vue component is a black box from the outside. It receives props, renders output, and emits events. The test should assert on those three things—what goes in, what comes out, and what the user sees—rather than on the internal refs, computed properties, or methods. Vue Test Utils is the library that mounts the component into a test DOM and provides the queries and interactions that make those assertions possible. Vitest is the test runner that executes the test file, provides the describe/it/expect API, and integrates with Vite’s transform pipeline so that .vue files are compiled without a separate build step .
The pair is the official recommendation for Vue 3 projects. Vitest is maintained by the same team as Vue and Vite, and it reuses the project’s vite.config.js for the transform pipeline . The alternative—Jest with vue-jest—requires a separate configuration for the Vue compiler and a separate transform pipeline, which is slower and more fragile when the Vite config changes . For a new project, the setup is three packages and a test block in the Vite config. For an existing project, the migration is mostly a change of the test runner and the import of the Vitest globals.
Key point: Vitest is the test runner and Vue Test Utils is the mounting library. mount(Component, options) renders the component into a test DOM and returns a wrapper; the wrapper’s find, findAll, findComponent, and findAllComponents methods query the rendered output; setValue and trigger simulate user input; and emitted asserts on the component’s output. Every DOM-updating method returns a Promise and must be awaited, because Vue updates the DOM asynchronously.
Why Vitest and Vue Test Utils are the pair
The transform problem. A .vue file is not valid JavaScript. It has a <template>, a <script>, and a <style> block, and the test runner must compile it before it can import it. Vitest uses the same @vitejs/plugin-vue that the application uses, so the test sees the same compiled output the browser sees. Jest requires vue-jest and a separate transform configuration, and that configuration can drift from the Vite config when the application adds a new plugin or a new compiler option .
The speed problem. Vitest runs on Vite’s native ESM pipeline, which means the test file is not bundled before it runs. The startup is faster than Jest’s Babel-based pipeline, and the watch mode re-runs only the files that changed . The performance difference is not the main reason to choose Vitest, but it is the reason the watch-mode development loop feels faster.
The mounting problem. Vue Test Utils is not a test runner. It is a set of utilities that depend on a DOM environment. It needs jsdom or happy-dom to provide document, window, and the DOM APIs that the component uses. Vitest configures the environment, and Vue Test Utils uses it. The separation is deliberate: the runner provides the environment and the assertion API, the mounting library provides the component-specific queries and interactions .
The configuration problem. Vitest reads the test block from vite.config.js or from a separate vitest.config.js. The plugins: [vue()] line is the same plugin the application uses. The environment: 'happy-dom' or 'jsdom' line tells Vitest which DOM implementation to use. The globals: true line makes describe, it, and expect available without imports, which is convenient but optional .
a. The setup and the mount options
The setup requires three packages: vitest for the runner, @vue/test-utils for the mounting, and happy-dom or jsdom for the DOM environment . The vite.config.js gets a test block, and the package.json gets the test scripts.
// vite.config.js
import { defineConfig } from 'vite'
// The Vue plugin compiles the .vue files for the test runner
import vue from '@vitejs/plugin-vue'
export default defineConfig({
// The same plugin the app uses
plugins: [vue()],
test: {
// Make describe, it, expect available without imports
globals: true,
// Use happy-dom for speed; use jsdom for broader compatibility
environment: 'happy-dom',
// Optional: run a setup file before every test file
setupFiles: ['./src/test/setup.js'],
},
})
// package.json
{
"scripts": {
// The watch mode for development
"test": "vitest",
// The single run for CI
"test:run": "vitest run",
// The coverage report
"test:coverage": "vitest run --coverage"
}
}
The mount function takes the component and an options object. The options control the props, the slots, the global plugins, and the stubs.
// Counter.test.js
import { describe, it, expect } from 'vitest'
// Import the mounting function
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
describe('Counter', () => {
it('renders the initial count', () => {
// Mount the component with the initialCount prop
const wrapper = mount(Counter, {
props: { initialCount: 5 },
})
// The wrapper is the mounted component
// text() returns the text content of the root element
expect(wrapper.text()).toContain('5')
})
it('increments when the button is clicked', async () => {
// Mount the component
const wrapper = mount(Counter, {
props: { initialCount: 0 },
})
// Find the button and trigger a click
// trigger returns a Promise that resolves after Vue updates the DOM
await wrapper.find('button').trigger('click')
// Assert on the rendered output, not the internal state
expect(wrapper.text()).toContain('1')
})
})
The mount options include props, slots, global.plugins, global.stubs, global.mocks, and attachTo. The attachTo option is required for tests that assert on focus, visibility, or portals, because a component that is not attached to the document cannot receive focus and its portal target is not in the document . The global.stubs option replaces child components with stubs, which is useful when the child is not the subject of the test and its rendering is expensive or requires a global plugin.
b. The wrapper queries and the interaction methods
The wrapper is the interface to the mounted component. Its queries return either a DOMWrapper (for an element) or a VueWrapper (for a child component). The find method returns a single wrapper and throws if the element is missing. The findAll method returns an array and does not throw.
import { mount } from '@vue/test-utils'
import Form from './Form.vue'
it('fills and submits the form', async () => {
const wrapper = mount(Form)
// find returns a DOMWrapper for the input
// setValue sets the value and triggers the input event
await wrapper.find('input[type="email"]').setValue('a@b.com')
// find returns a DOMWrapper for the textarea
await wrapper.find('textarea').setValue('message')
// find returns a DOMWrapper for the select
// setValue selects the option by its value
await wrapper.find('select').setValue('moscow')
// find returns a DOMWrapper for the checkbox
// setValue with no argument checks the box
await wrapper.find('input[type="checkbox"]').setValue()
// find returns a DOMWrapper for the form
// trigger fires the submit event
// The .prevent modifier is handled by Vue's compiler
await wrapper.find('form').trigger('submit')
// emitted returns a record of event name to emissions
// The first emission is the first time the event fired
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')[0][0]).toEqual({
email: 'a@b.com',
message: 'message',
city: 'moscow',
subscribe: true,
})
})
The setValue method is more than a property assignment. It sets the element’s value and triggers the appropriate event—input for text inputs and textareas, change for selects and checkboxes. The component’s v-model listener fires, and the reactive state updates. A test that writes to wrapper.vm.email directly skips the event and the component’s validation logic, which means the test can pass while the real form is broken .
The trigger method dispatches the event and returns a Promise that resolves after Vue flushes the DOM update. The await is not optional: Vue’s updates are asynchronous, and an assertion that runs before the flush sees the old DOM . The findAll method returns an array of wrappers, and the findAllComponents method returns an array of VueWrapper instances for child components.
c. Testing v-model, emitted events, and child components
The v-model test is the case that confuses most developers. A component that implements v-model receives a modelValue prop and emits update:modelValue. The test must set the prop, trigger the input, and assert on the emitted event, not on the prop.
import { mount } from '@vue/test-utils'
import BaseInput from './BaseInput.vue'
it('emits update:modelValue when the input changes', async () => {
// Mount with the initial modelValue
const wrapper = mount(BaseInput, {
props: {
modelValue: 'initial',
// The parent listens for the update and sets the prop
// This simulates v-model without a parent component
'onUpdate:modelValue': (value) => wrapper.setProps({ modelValue: value }),
},
})
// Set the input's value
// setValue triggers the input event, which emits update:modelValue
await wrapper.find('input').setValue('new value')
// Assert the emitted event
// The payload is the new value
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['new value'])
// Assert the prop was updated by the simulated parent
expect(wrapper.props('modelValue')).toBe('new value')
})
The onUpdate:modelValue prop is the key. Vue’s v-model compiles to a modelValue prop and an update:modelValue listener. Without the listener, the emitted event is recorded but the prop does not change, and the component’s rendered value does not update. The test can either assert on the emitted event alone or simulate the parent by calling setProps in the listener .
The child component test uses findComponent or findAllComponents. The findComponent method accepts the component definition, the component name as a string, or a ref option. The returned wrapper is a VueWrapper, which has the props, emitted, and vm accessors that a DOMWrapper does not .
import { mount } from '@vue/test-utils'
import Parent from './Parent.vue'
import Child from './Child.vue'
it('passes the prop to the child', () => {
const wrapper = mount(Parent)
// findComponent returns a VueWrapper for the child
const child = wrapper.findComponent(Child)
// The props method returns the child's props
expect(child.props('message')).toBe('hello')
})
it('handles the child event', async () => {
const wrapper = mount(Parent)
// Trigger an event on the child
// The child's emitted event is recorded on the child wrapper
await wrapper.findComponent(Child).vm.$emit('confirm', 'yes')
// The parent's handler runs and the parent's state updates
expect(wrapper.text()).toContain('confirmed')
})
The findAllComponents method is the tool for a list of child components. It returns an array of VueWrapper instances, and the array’s length is the number of rendered children. The find method is deprecated for component lookup; the findAllComponents method is the replacement .
Complete Example Session
The following session builds a test suite for a login form with a v-model input, a submit event, and a child component.
// ============================================
// PART 1: THE COMPONENT UNDER TEST
// ============================================
// LoginForm.vue
<script setup>
import { ref } from 'vue'
import BaseInput from './BaseInput.vue'
const email = ref('')
const password = ref('')
const emit = defineEmits(['submit'])
function handleSubmit() {
// Emit the credentials
emit('submit', { email: email.value, password: password.value })
}
</script>
<template>
<!-- The BaseInput is the child component -->
<BaseInput v-model="email" label="Email" data-testid="email" />
<BaseInput v-model="password" label="Password" type="password" data-testid="password" />
<button @click="handleSubmit">Sign in</button>
</template>
// ============================================
// PART 2: THE TEST
// ============================================
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import LoginForm from './LoginForm.vue'
import BaseInput from './BaseInput.vue'
describe('LoginForm', () => {
it('emits the credentials on submit', async () => {
// Mount the form
const wrapper = mount(LoginForm)
// Find the email input by its test id
// setValue triggers the input event and updates the v-model
await wrapper.find('[data-testid="email"] input').setValue('a@b.com')
// Find the password input by its test id
await wrapper.find('[data-testid="password"] input').setValue('secret')
// Click the submit button
await wrapper.find('button').trigger('click')
// Assert the emitted event and its payload
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')[0][0]).toEqual({
email: 'a@b.com',
password: 'secret',
})
})
it('renders two BaseInput children', () => {
const wrapper = mount(LoginForm)
// findAllComponents returns an array of child wrappers
const inputs = wrapper.findAllComponents(BaseInput)
// There are two inputs: email and password
expect(inputs).toHaveLength(2)
})
})
The two parts cover the component with the v-model child and the submit event, and the test that sets the values, triggers the submit, and asserts on the emitted payload. What the session demonstrates is that the test drives the inputs through setValue, not through wrapper.vm, and the assertion is on the component’s output, not its internal state.
Quick Reference
Setup Packages
| Package | Purpose |
|---|---|
vitest | Test runner |
@vue/test-utils | Mounting and queries |
happy-dom or jsdom | DOM environment |
Mount Options
| Option | Purpose |
|---|---|
props | Pass props |
slots | Pass slot content |
global.plugins | Install plugins |
global.stubs | Stub child components |
global.mocks | Mock global properties |
attachTo | Attach to document |
Wrapper Queries
| Method | Returns | Missing |
|---|---|---|
find(selector) | DOMWrapper | Throws |
findAll(selector) | DOMWrapper[] | Empty array |
findComponent(comp) | VueWrapper | Throws |
findAllComponents(comp) | VueWrapper[] | Empty array |
Interaction Methods
| Method | Effect |
|---|---|
setValue(value) | Set value and trigger event |
trigger(event) | Dispatch event |
setProps(props) | Update props |
emitted(event) | Record of emissions |
Best Practices
✅ Do This:
// Use setValue for form inputs
await wrapper.find('input').setValue('text')
// Use trigger for events
await wrapper.find('form').trigger('submit')
// Assert on emitted events
expect(wrapper.emitted('submit')[0][0]).toEqual({ ... })
// Use findAllComponents for child lists
expect(wrapper.findAllComponents(Child)).toHaveLength(2)
❌ Don’t Do This:
// Write to vm directly
wrapper.vm.email = 'a@b.com' // skips the event and validation
// Forget to await
wrapper.find('button').trigger('click') // assertion runs too early
// Call the handler directly
wrapper.vm.handleSubmit() // bypasses the DOM
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Assertion sees old DOM | No await on trigger | await wrapper.find(...).trigger(...) |
v-model not updating | No onUpdate:modelValue listener | Simulate the parent with setProps |
| Child component not found | Used find instead of findComponent | findComponent(Child) |
| Focus test fails | Component not attached | attachTo: document.body |
| Async data missing | No flushPromises | await flushPromises() |
Real-World Examples
1. Mount with props
const wrapper = mount(Counter, { props: { initial: 5 } })
2. Set value
await wrapper.find('input').setValue('text')
3. Trigger click
await wrapper.find('button').trigger('click')
4. Assert emitted
expect(wrapper.emitted('submit')[0][0]).toEqual({ ... })
5. Find child
const child = wrapper.findComponent(Child)
6. Find all children
expect(wrapper.findAllComponents(Child)).toHaveLength(2)
7. v-model simulation
'onUpdate:modelValue': (v) => wrapper.setProps({ modelValue: v })
8. Attach to document
mount(Comp, { attachTo: document.body })
9. Flush async
await flushPromises()
10. Stub child
mount(Parent, { global: { stubs: { Child: true } } })
Visual
Test Flow
┌──────────────────────────────────────────────┐
│ mount(Component, options) │
│ │ │
│ ▼ │
│ wrapper │
│ │ │
│ ├── find(selector) → DOMWrapper │
│ ├── findComponent(Comp) → VueWrapper │
│ │ │
│ ▼ │
│ await setValue / trigger │
│ │ │
│ ▼ │
│ Vue flushes DOM update │
│ │ │
│ ▼ │
│ assert on text, emitted, props │
└──────────────────────────────────────────────┘
v-model Test
┌──────────────────────────────────────────────┐
│ mount(BaseInput, { │
│ props: { modelValue: 'initial' }, │
│ 'onUpdate:modelValue': (v) => │
│ wrapper.setProps({ modelValue: v }) │
│ }) │
│ │ │
│ ▼ │
│ await find('input').setValue('new') │
│ │ │
│ ▼ │
│ emitted('update:modelValue')[0] === ['new'] │
└──────────────────────────────────────────────┘
Async Flush
┌──────────────────────────────────────────────┐
│ await trigger('click') │
│ → Vue schedules the update │
│ → the Promise resolves after the flush │
│ │
│ await flushPromises() │
│ → waits for pending promises │
│ → for API calls and timers │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Test runner | vitest |
| Mounting library | @vue/test-utils |
| DOM environment | happy-dom or jsdom |
| Mount function | mount(Component, options) |
| Element query | find, findAll |
| Component query | findComponent, findAllComponents |
| Input simulation | setValue |
| Event simulation | trigger |
| Event assertion | emitted |
| Async wait | await on interactions, flushPromises |
Key takeaways:
- Vitest is the runner, Vue Test Utils is the mounting library. Vitest provides the
describe/it/expectAPI and the Vite transform pipeline. Vue Test Utils provides themountfunction and the wrapper queries . mountrenders the component and returns a wrapper. The wrapper’sfindandfindComponentmethods query the rendered output, and thesetValueandtriggermethods simulate user input .setValueis not a property assignment. It sets the element’s value and triggers the appropriate event, which is what drives the component’sv-modeland validation logic. Writing towrapper.vmdirectly skips the event .- Every DOM-updating method must be awaited. Vue’s DOM updates are asynchronous, and an assertion that runs before the flush sees the old DOM .
v-modeltests need theonUpdate:modelValuelistener. The emitted event is recorded, but the prop does not change unless the test simulates the parent by callingsetPropsin the listener .findComponentis the child component query. It returns aVueWrapperwithprops,emitted, andvmaccessors. ThefindAllComponentsmethod returns an array for lists .
Remember: A Vue component test is a test of the component’s contract, not its internals. The contract is the props it receives, the output it renders, and the events it emits. Vue Test Utils gives you the tools to drive that contract—mount to render, find to query, setValue and trigger to interact, emitted to assert—and Vitest gives you the runner and the assertion API. The await on every interaction is not a formality; it is the synchronization point between the test and Vue’s asynchronous DOM updates. A test that writes to wrapper.vm or calls a method directly is a test that skips the contract, and it will pass while the component is broken for the user.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!