| |

Vue.js 69 🟢 Unit Testing Vue Components with Vitest and Vue Test Utils

⏱️ Estimated read: 14 min | |

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

PackagePurpose
vitestTest runner
@vue/test-utilsMounting and queries
happy-dom or jsdomDOM environment

Mount Options

OptionPurpose
propsPass props
slotsPass slot content
global.pluginsInstall plugins
global.stubsStub child components
global.mocksMock global properties
attachToAttach to document

Wrapper Queries

MethodReturnsMissing
find(selector)DOMWrapperThrows
findAll(selector)DOMWrapper[]Empty array
findComponent(comp)VueWrapperThrows
findAllComponents(comp)VueWrapper[]Empty array

Interaction Methods

MethodEffect
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

PitfallWhy It HappensFix
Assertion sees old DOMNo await on triggerawait wrapper.find(...).trigger(...)
v-model not updatingNo onUpdate:modelValue listenerSimulate the parent with setProps
Child component not foundUsed find instead of findComponentfindComponent(Child)
Focus test failsComponent not attachedattachTo: document.body
Async data missingNo flushPromisesawait 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

ItemValue
Test runnervitest
Mounting library@vue/test-utils
DOM environmenthappy-dom or jsdom
Mount functionmount(Component, options)
Element queryfind, findAll
Component queryfindComponent, findAllComponents
Input simulationsetValue
Event simulationtrigger
Event assertionemitted
Async waitawait on interactions, flushPromises

Key takeaways:

  • Vitest is the runner, Vue Test Utils is the mounting library. Vitest provides the describe/it/expect API and the Vite transform pipeline. Vue Test Utils provides the mount function and the wrapper queries .
  • mount renders the component and returns a wrapper. The wrapper’s find and findComponent methods query the rendered output, and the setValue and trigger methods simulate user input .
  • setValue is not a property assignment. It sets the element’s value and triggers the appropriate event, which is what drives the component’s v-model and validation logic. Writing to wrapper.vm directly 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-model tests need the onUpdate:modelValue listener. The emitted event is recorded, but the prop does not change unless the test simulates the parent by calling setProps in the listener .
  • findComponent is the child component query. It returns a VueWrapper with props, emitted, and vm accessors. The findAllComponents method 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.


Was this helpful?


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!