On this page

Testing Vue 3 components and composables with Vitest and Vue Test Utils

14 min read TextCh. 5 — Production

Testing in Vue 3

A well-tested Vue application gives you confidence to refactor, add features, and deploy without fear. The testing stack for Vue 3 consists of:

  • Vitest — the recommended test runner (Vite-native, extremely fast)
  • Vue Test Utils (VTU) — the official library for mounting and interacting with Vue components
  • @testing-library/vue — an alternative to VTU focused on accessibility-based queries
  • jsdom or happy-dom — simulates the browser DOM in Node.js

Setting up the testing environment

When you scaffold with npm create vue@latest and select Vitest, everything is configured automatically. Manual setup:

npm install -D vitest @vue/test-utils @testing-library/vue happy-dom

vite.config.ts:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'happy-dom', // or 'jsdom'
    globals: true,            // No need to import describe, it, expect
  },
})

Run tests:

npx vitest         # Watch mode
npx vitest run     # Single run (CI)
npx vitest --ui    # Browser-based UI

Mounting components with Vue Test Utils

mount() creates a full component instance including child components. shallowMount() stubs all child components:

import { mount, shallowMount } from '@vue/test-utils'
import MyComponent from './MyComponent.vue'

// Full mount — all children render
const wrapper = mount(MyComponent, {
  props: { title: 'Hello', count: 3 },
  global: {
    // Provide plugins, mocks, stubs
    plugins: [router, pinia],
    stubs: { RouterLink: true },
    provide: { theme: 'dark' },
  },
})

Querying elements

// Single element — throws if not found
const el = wrapper.get('[data-testid="submit"]')

// Single element — returns DOMWrapper or empty wrapper (no throw)
const maybe = wrapper.find('.optional-element')
if (maybe.exists()) { /* ... */ }

// All matching elements
const items = wrapper.findAll('li')

// Component instances
const child = wrapper.findComponent(MyChildComponent)

Triggering events

// Click
await wrapper.get('button').trigger('click')

// Input
await wrapper.get('input').setValue('new value')

// Keyboard event
await wrapper.get('input').trigger('keyup.enter')

// Custom event from child component
await wrapper.findComponent(ChildComp).vm.$emit('customEvent', payload)

Reading values

wrapper.text()                    // All text content
wrapper.html()                    // Inner HTML
wrapper.get('p').text()           // Text of specific element
wrapper.get('input').element.value // DOM input value
wrapper.props()                   // Current prop values
wrapper.emitted('update:modelValue') // Emitted events

Testing component behavior

A complete test suite covers: rendering, user interaction, prop variations, and edge cases.

import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import SearchBar from './SearchBar.vue'

describe('SearchBar', () => {
  it('emits search event when form is submitted', async () => {
    const wrapper = mount(SearchBar)
    await wrapper.get('input').setValue('vue 3')
    await wrapper.get('form').trigger('submit')
    expect(wrapper.emitted('search')).toEqual([['vue 3']])
  })

  it('clears input after submission', async () => {
    const wrapper = mount(SearchBar)
    const input = wrapper.get('input')
    await input.setValue('test query')
    await wrapper.get('form').trigger('submit')
    expect((input.element as HTMLInputElement).value).toBe('')
  })

  it('does not emit when input is empty', async () => {
    const wrapper = mount(SearchBar)
    await wrapper.get('form').trigger('submit')
    expect(wrapper.emitted('search')).toBeUndefined()
  })
})

Testing composables

Composables without lifecycle hooks can be tested by calling them directly:

import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('starts at the initial value', () => {
    const { count } = useCounter({ initial: 5 })
    expect(count.value).toBe(5)
  })

  it('increments correctly', () => {
    const { count, increment } = useCounter()
    increment()
    expect(count.value).toBe(1)
  })

  it('respects the max boundary', () => {
    const { count, increment, isAtMax } = useCounter({ initial: 9, max: 10 })
    increment()
    expect(isAtMax.value).toBe(true)
    increment()
    expect(count.value).toBe(10) // Did not exceed max
  })
})

For composables with lifecycle hooks, use a withSetup helper:

import { createApp } from 'vue'

function withSetup<T>(composable: () => T): [T, ReturnType<typeof createApp>] {
  let result!: T
  const app = createApp({ setup() { result = composable(); return {} } })
  app.mount(document.createElement('div'))
  return [result, app]
}

// Usage
it('cleans up on unmount', () => {
  const [{ isListening }, app] = withSetup(() => useEventListener(...))
  expect(isListening.value).toBe(true)
  app.unmount()
  expect(isListening.value).toBe(false)
})

Testing Pinia stores

import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCartStore } from './cart'

describe('useCartStore', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('starts with an empty cart', () => {
    const cart = useCartStore()
    expect(cart.items).toHaveLength(0)
    expect(cart.total).toBe(0)
  })

  it('adds items correctly', () => {
    const cart = useCartStore()
    cart.addItem({ id: 1, name: 'Apple', price: 1.5 })
    cart.addItem({ id: 1, name: 'Apple', price: 1.5 }) // same item
    expect(cart.items[0].quantity).toBe(2)
    expect(cart.subtotal).toBe(3)
  })

  it('removes items', () => {
    const cart = useCartStore()
    cart.addItem({ id: 1, name: 'Apple', price: 1.5 })
    cart.removeItem(1)
    expect(cart.isEmpty).toBe(true)
  })
})

Mocking external dependencies

import { vi } from 'vitest'

// Mock fetch
global.fetch = vi.fn().mockResolvedValue({
  ok: true,
  json: () => Promise.resolve([{ id: 1, title: 'Test Post' }]),
})

// Mock a module
vi.mock('@/services/api', () => ({
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Ada' }),
}))

// Restore mocks between tests
afterEach(() => vi.restoreAllMocks())

Component testing with @testing-library/vue

Testing Library focuses on querying elements the way users do — by visible text, labels, and ARIA roles:

import { render, screen, fireEvent } from '@testing-library/vue'
import { userEvent } from '@testing-library/user-event'
import LoginForm from './LoginForm.vue'

it('logs in with valid credentials', async () => {
  const user = userEvent.setup()
  render(LoginForm)

  await user.type(screen.getByLabelText('Email'), '[email protected]')
  await user.type(screen.getByLabelText('Password'), 'secret123')
  await user.click(screen.getByRole('button', { name: 'Log in' }))

  expect(screen.getByText('Welcome!')).toBeInTheDocument()
})

Coverage reports

npx vitest run --coverage

Configure thresholds in vite.config.ts:

test: {
  coverage: {
    provider: 'v8',
    thresholds: {
      statements: 80,
      branches: 75,
      functions: 80,
      lines: 80,
    },
  },
}

Practice

  1. Test a form component: Write tests for a RegistrationForm.vue that verify: required field validation appears on blur, invalid email shows an error, password mismatch shows an error, and the submit button is disabled until the form is valid.
  2. Test a Pinia store: Create a useProductStore with async fetchProducts(). Test it with a mocked fetch, verifying loading/error/data states.
  3. 100% coverage target: Pick a composable from the previous lessons and write tests until you reach 100% statement coverage. Use npx vitest run --coverage to measure.

In the final lesson, you will apply everything you have learned by building a complete contact manager application from scratch using Vue 3.5, Composition API, Pinia, and TypeScript.

data-testid for stable selectors
Use data-testid attributes as selectors in tests instead of CSS classes or tag names. Class names change with styling refactors and tag names change with HTML refactors — data-testid attributes exist only for testing and are stable.
Testing composables directly
Composables that only use ref/computed/watch can be tested by calling them directly in a test. For composables that use lifecycle hooks (onMounted, onUnmounted), wrap the call in withSetup — a helper that creates a minimal component instance.
Always await async operations in tests
After triggering a click or setting a value, Vue batches DOM updates asynchronously. Always await trigger() and setProps() calls (they return Promises) and use nextTick() after any reactive state change before asserting the DOM.
<script setup lang="ts">
import { ref, computed } from 'vue'

const props = defineProps<{
  initial?: number
  max?: number
}>()

const count = ref(props.initial ?? 0)
const isAtMax = computed(() => props.max !== undefined && count.value >= props.max)

function increment() {
  if (!isAtMax.value) count.value++
}

function reset() {
  count.value = props.initial ?? 0
}
</script>

<template>
  <div>
    <p data-testid="count">{{ count }}</p>
    <button
      type="button"
      data-testid="increment"
      :disabled="isAtMax"
      @click="increment"
    >+</button>
    <button
      type="button"
      data-testid="reset"
      @click="reset"
    >Reset</button>
    <p v-if="isAtMax" data-testid="max-message">
      Maximum reached!
    </p>
  </div>
</template>