---
title: "Your first component test with Vitest and Vue Test Utils · vuejs.school"
description: "Set up Vitest 3 with happy-dom in a create-vue project, mount a Counter, click its button and assert the text, then test a prop and an emitted event."
canonical_url: "https://vuejs.school/community/blog/your-first-component-test-with-vitest-and-vue-test-utils"
meta:
  "og:description": "Set up Vitest 3 with happy-dom in a create-vue project, mount a Counter, click its button and assert the text, then test a prop and an emitted event."
  "og:image": "https://vuejs.school/_og/d/a_~WW91ciBmaXJzdCBjb21wb25lbnQgdGVzdCB3aXRoIFZpdGVzdCBhbmQgVnVlIFRlc3QgVXRpbHMgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Your%2Bfirst%2Bcomponent%2Btest%2Bwith%2BVitest%2Band%2BVue%2BTest%2BUtils%2Cdescription_~U2V0IHVwIFZpdGVzdCAzIHdpdGggaGFwcHktZG9tIGluIGEgY3JlYXRlLXZ1ZSBwcm9qZWN0LCBtb3VudCBhIENvdW50ZXIsIGNsaWNrIGl0cyBidXR0b24gYW5kIGFzc2VydCB0aGUgdGV4dCwgdGhlbiB0ZXN0IGEgcHJvcCBhbmQgYW4gZW1pdHRlZCBldmVudC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy95b3VyLWZpcnN0LWNvbXBvbmVudC10ZXN0LXdpdGgtdml0ZXN0LWFuZC12dWUtdGVzdC11dGlscyI%2Cs_Ge_BQFtyiXjZPV1k.png"
  "og:image:alt": "Your first component test with Vitest and Vue Test Utils · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Your first component test with Vitest and Vue Test Utils · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~WW91ciBmaXJzdCBjb21wb25lbnQgdGVzdCB3aXRoIFZpdGVzdCBhbmQgVnVlIFRlc3QgVXRpbHMgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Your%2Bfirst%2Bcomponent%2Btest%2Bwith%2BVitest%2Band%2BVue%2BTest%2BUtils%2Cdescription_~U2V0IHVwIFZpdGVzdCAzIHdpdGggaGFwcHktZG9tIGluIGEgY3JlYXRlLXZ1ZSBwcm9qZWN0LCBtb3VudCBhIENvdW50ZXIsIGNsaWNrIGl0cyBidXR0b24gYW5kIGFzc2VydCB0aGUgdGV4dCwgdGhlbiB0ZXN0IGEgcHJvcCBhbmQgYW4gZW1pdHRlZCBldmVudC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy95b3VyLWZpcnN0LWNvbXBvbmVudC10ZXN0LXdpdGgtdml0ZXN0LWFuZC12dWUtdGVzdC11dGlscyI%2Cs_Ge_BQFtyiXjZPV1k.png"
  "twitter:image:alt": "Your first component test with Vitest and Vue Test Utils · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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 `await`ing 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 stuck**3 questions from the Vitest and Vue Test Utils quiz.

[**Practice now**](https://vuejs.school/learn/testing/vitest-and-vue-test-utils?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Release notes · 6 min read**Vitest 5: what changed for Vue tests**](https://vuejs.school/community/blog/vitest-5-what-changed-for-vue-tests) [Release notes · 6 min read**Vitest 5 release candidate: what's coming for Vue tests**](https://vuejs.school/community/blog/vitest-5-rc-whats-coming)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/your-first-component-test-with-vitest-and-vue-test-utils#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-02-20","description":"Set up Vitest 3 with happy-dom in a create-vue project, mount a Counter, click its button and assert the text, then test a prop and an emitted event.","name":"Your first component test with Vitest and Vue Test Utils","url":"https://vuejs.school/community/blog/your-first-component-test-with-vitest-and-vue-test-utils","breadcrumb":{"@id":"https://vuejs.school/community/blog/your-first-component-test-with-vitest-and-vue-test-utils#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/your-first-component-test-with-vitest-and-vue-test-utils"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/your-first-component-test-with-vitest-and-vue-test-utils#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-02-20","description":"Set up Vitest 3 with happy-dom in a create-vue project, mount a Counter, click its button and assert the text, then test a prop and an emitted event.","headline":"Your first component test with Vitest and Vue Test Utils","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~WW91ciBmaXJzdCBjb21wb25lbnQgdGVzdCB3aXRoIFZpdGVzdCBhbmQgVnVlIFRlc3QgVXRpbHMgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Your%2Bfirst%2Bcomponent%2Btest%2Bwith%2BVitest%2Band%2BVue%2BTest%2BUtils%2Cdescription_~U2V0IHVwIFZpdGVzdCAzIHdpdGggaGFwcHktZG9tIGluIGEgY3JlYXRlLXZ1ZSBwcm9qZWN0LCBtb3VudCBhIENvdW50ZXIsIGNsaWNrIGl0cyBidXR0b24gYW5kIGFzc2VydCB0aGUgdGV4dCwgdGhlbiB0ZXN0IGEgcHJvcCBhbmQgYW4gZW1pdHRlZCBldmVudC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy95b3VyLWZpcnN0LWNvbXBvbmVudC10ZXN0LXdpdGgtdml0ZXN0LWFuZC12dWUtdGVzdC11dGlscyI%2Cs_Ge_BQFtyiXjZPV1k.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/your-first-component-test-with-vitest-and-vue-test-utils#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/your-first-component-test-with-vitest-and-vue-test-utils#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/your-first-component-test-with-vitest-and-vue-test-utils#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Your first component test with Vitest and Vue Test Utils","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~WW91ciBmaXJzdCBjb21wb25lbnQgdGVzdCB3aXRoIFZpdGVzdCBhbmQgVnVlIFRlc3QgVXRpbHMgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Your%2Bfirst%2Bcomponent%2Btest%2Bwith%2BVitest%2Band%2BVue%2BTest%2BUtils%2Cdescription_~U2V0IHVwIFZpdGVzdCAzIHdpdGggaGFwcHktZG9tIGluIGEgY3JlYXRlLXZ1ZSBwcm9qZWN0LCBtb3VudCBhIENvdW50ZXIsIGNsaWNrIGl0cyBidXR0b24gYW5kIGFzc2VydCB0aGUgdGV4dCwgdGhlbiB0ZXN0IGEgcHJvcCBhbmQgYW4gZW1pdHRlZCBldmVudC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy95b3VyLWZpcnN0LWNvbXBvbmVudC10ZXN0LXdpdGgtdml0ZXN0LWFuZC12dWUtdGVzdC11dGlscyI%2Cs_Ge_BQFtyiXjZPV1k.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~WW91ciBmaXJzdCBjb21wb25lbnQgdGVzdCB3aXRoIFZpdGVzdCBhbmQgVnVlIFRlc3QgVXRpbHMgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Your%2Bfirst%2Bcomponent%2Btest%2Bwith%2BVitest%2Band%2BVue%2BTest%2BUtils%2Cdescription_~U2V0IHVwIFZpdGVzdCAzIHdpdGggaGFwcHktZG9tIGluIGEgY3JlYXRlLXZ1ZSBwcm9qZWN0LCBtb3VudCBhIENvdW50ZXIsIGNsaWNrIGl0cyBidXR0b24gYW5kIGFzc2VydCB0aGUgdGV4dCwgdGhlbiB0ZXN0IGEgcHJvcCBhbmQgYW4gZW1pdHRlZCBldmVudC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy95b3VyLWZpcnN0LWNvbXBvbmVudC10ZXN0LXdpdGgtdml0ZXN0LWFuZC12dWUtdGVzdC11dGlscyI%2Cs_Ge_BQFtyiXjZPV1k.png"}]}
```