On this page
Testing Vue 3 components and composables with Vitest and Vue Test Utils
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-domvite.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 UIMounting 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 eventsTesting 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 --coverageConfigure thresholds in vite.config.ts:
test: {
coverage: {
provider: 'v8',
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
}Practice
- Test a form component: Write tests for a
RegistrationForm.vuethat 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. - Test a Pinia store: Create a
useProductStorewith asyncfetchProducts(). Test it with a mockedfetch, verifying loading/error/data states. - 100% coverage target: Pick a composable from the previous lessons and write tests until you reach 100% statement coverage. Use
npx vitest run --coverageto 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.
<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>
Sign in to track your progress