Skip to content
TutorialTesting

Your first component test with Vitest and Vue Test Utils

A component test renders one component in a pretend browser, pokes it, and checks what came out. Three tests for a Counter cover the three things you will test most: text, props and events.

vuejs.school team20 Feb 20256 min readVitest 3.0 · Vue Test Utils 2.4 · happy-dom 17

Clicking through your app after every change is a slow way to check that a component still works. A component test does the clicking for you: it renders the component, clicks a button and checks the result in milliseconds, every time you save.

You will set up Vitest in a create-vue project and write three tests for a small Counter component: one for the text a click changes, one for a prop, and one for an emitted event. You will also see why the line that clicks starts with await, and what goes wrong without it.

What Vitest, Vue Test Utils and happy-dom each do

Vitest is the test runner. It finds files that end in .spec.ts or .test.ts, runs them, and reports what passed. It is built on Vite, so it reads your vite.config.ts and understands .vue files without extra setup. Version 3.0 shipped on 16 January and is what create-vue installs.

Vue Test Utils (VTU) is the library that renders a component for a test and gives you a wrapper object to inspect it: find elements, click them, read the text, see which events were emitted. Version 2.4 is current and works with Vitest 3.

happy-dom is a pretend browser. Tests run in Node, and Node has no document, no window and no DOM. happy-dom provides them in JavaScript, fast enough that a test takes milliseconds. The alternative is jsdom; it does the same job and is more complete, but it is slower to start, and for component tests happy-dom is enough. Use happy-dom.

Set up Vitest with happy-dom

The quickest start is a new project. Run npm create vue@latest and answer yes to "Add TypeScript?" (the examples here use it) and to "Add Vitest for Unit Testing?". That gives you vitest, @vue/test-utils and jsdom in devDependencies, a test:unit script, and an example test under src/components/__tests__/. Swap jsdom for happy-dom:

npm install -D happy-dom
npm uninstall jsdom

Then open the generated vitest.config.ts and change one word:

vitest.config.ts
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'

export default mergeConfig(
  viteConfig,
  defineConfig({
    test: {
      environment: 'happy-dom',
      exclude: [...configDefaults.exclude, 'e2e/**'],
      root: fileURLToPath(new URL('./', import.meta.url)),
    },
  }),
)

mergeConfig takes your Vite config (plugins, the @ alias) and adds the test block on top. environment: 'happy-dom' is the line that gives every test a document. The exclude entry keeps Vitest away from end-to-end tests if you add Playwright or Cypress later.

For an existing Vite project, install the three packages (npm install -D vitest @vue/test-utils happy-dom), create the file above, and add "test:unit": "vitest" to the scripts in package.json. Vitest 3 needs Node 18 or newer; Node 20 or 22 is what you should be running anyway.

A Counter component to test

A Counter with a starting value and an event. Small on purpose, so the tests are about testing and not about the component.

src/components/Counter.vue
<script setup lang="ts">
import { ref } from 'vue'

const props = withDefaults(defineProps<{ start?: number }>(), { start: 0 })
const emit = defineEmits<{ change: [count: number] }>()

const count = ref(props.start)

function increment() {
  count.value++
  emit('change', count.value)
}
</script>

<template>
  <button @click="increment">Clicked {{ count }} times</button>
</template>

The start prop sets the first value. Every click adds one and emits change with the new number, so a parent can react.

Click the button and check the text

Create src/components/__tests__/Counter.spec.ts:

src/components/__tests__/Counter.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from '../Counter.vue'

describe('Counter', () => {
  it('counts clicks', async () => {
    const wrapper = mount(Counter)

    expect(wrapper.text()).toContain('Clicked 0 times')

    await wrapper.find('button').trigger('click')

    expect(wrapper.text()).toContain('Clicked 1 times')
  })
})

Line by line: mount(Counter) renders the component into happy-dom's document and returns a wrapper. wrapper.text() is the rendered text, so the first expect checks the initial state. wrapper.find('button') looks up an element with a CSS selector and returns a wrapper around it. trigger('click') fires a real DOM click on it, which runs increment.

Now the await. When count changes, Vue does not update the DOM straight away. It waits until the current task finishes and then applies every change at once, in what Vue calls the next tick. trigger returns a promise that resolves after that tick, so awaiting it is enough. Without the await, the second expect would run before the DOM was updated, still see "Clicked 0 times", and fail although the component works.

Some changes do not go through trigger, such as calling a method on wrapper.vm (the component instance). Wait for those by hand with await nextTick(), imported from vue.

Run npm run test:unit. Vitest marks the test as passed, next to create-vue's example test, and keeps watching your files.

Test a prop

Add a second test inside the same describe block. Props go in the second argument to mount:

src/components/__tests__/Counter.spec.ts
it('starts from the start prop', () => {
  const wrapper = mount(Counter, { props: { start: 5 } })

  expect(wrapper.text()).toContain('Clicked 5 times')
})

No await here, because nothing changes after the first render.

The props object is typed from the component, so start: 'five' shows up as a type error in your editor and in npm run type-check (create-vue's script that runs vue-tsc). Vitest does not check types when it runs tests: it would still run that test, and Vue would only log an "Invalid prop" warning.

Test an emitted event

Add a third test to the same block. wrapper.emitted(name) returns every emission of that event, each as an array of its arguments:

src/components/__tests__/Counter.spec.ts
it('emits the new count on each click', async () => {
  const wrapper = mount(Counter, { props: { start: 2 } })
  const button = wrapper.find('button')

  await button.trigger('click')
  await button.trigger('click')

  expect(wrapper.emitted('change')).toEqual([[3], [4]])
})

Two clicks, two emissions: first with 3, then with 4. The outer array is the list of calls; each inner array is the arguments of one call. If change was never emitted, wrapper.emitted('change') is undefined, which makes toEqual fail with a clear message.

mount or shallowMount, find or findComponent

mount renders the component and everything inside it, children included. shallowMount renders the component but replaces every child component with an empty stub (a placeholder tag, such as <counter-stub> in place of a child Counter).

Use mount. A test that renders the real children checks what the user sees; a shallow test checks that you passed the right props to a stub, which is rarely the bug.

Reach for shallowMount only when a child does something you cannot run in a test, such as drawing on a canvas. Even then, stubbing that one child with the global.stubs option is usually better: mount(Dashboard, { global: { stubs: { SalesChart: true } } }) replaces only SalesChart with a stub.

find('button') takes a CSS selector and gives you a DOM element wrapper: you can trigger events on it and read its text() or attributes(). findComponent(ChildComponent) takes a component and gives you a component wrapper: you can read its props() and emitted(). If the Counter were inside a Cart component, a Cart test would use findComponent(Counter) to check that the Cart passed start correctly, and find('button') to click it as a user would.

Run the tests in watch mode and in CI

The test:unit script runs plain vitest, which starts in watch mode: it runs the tests, then waits and reruns only the files affected by each save. Keep it open in a terminal while you work. Press q to quit.

A CI (continuous integration) service, such as GitHub Actions, runs your tests on every push. There you want one run and an exit code that fails the build when a test fails, which is vitest run.

Vitest also notices the CI environment variable that GitHub Actions and most other services set, and switches to a single run on its own, so npm run test:unit works there too. The minimal GitHub Actions workflow below calls vitest run directly, so it does not depend on that variable:

.github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npx vitest run

Add --coverage to the last line once you have installed @vitest/coverage-v8, and Vitest prints which lines your tests never reached.

Check it stuck3 questions from the Vitest and Vue Test Utils quiz.
Practice now
Was this helpful?

Keep reading