Testing Vue 3 Components with Vitest
Learn to test Vue 3 components with Vitest and Vue Test Utils. Setup, testing props, emits, slots, composables, and mocking.
Written by Francisco Zapata
Testing is essential for maintaining robust applications. Vitest, the native testing framework for the Vite ecosystem, has become the preferred choice for testing Vue 3 applications thanks to its speed, Jest API compatibility, and minimal configuration.
Initial Setup
Installation
npm install -D vitest @vue/test-utils happy-dom
Vitest Configuration
In your vite.config.ts:
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
globals: true,
include: ['src/**/*.{test,spec}.{js,ts}']
}
})
Add scripts to package.json:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
Basic Component Testing
Given a simple component:
<!-- src/components/Counter.vue -->
<script setup>
import { ref } from 'vue'
const props = defineProps({
initial: { type: Number, default: 0 }
})
const count = ref(props.initial)
const increment = () => count.value++
const decrement = () => count.value--
</script>
<template>
<div class="counter">
<button data-testid="decrement" @click="decrement">-</button>
<span data-testid="count">{{ count }}</span>
<button data-testid="increment" @click="increment">+</button>
</div>
</template>
The corresponding test:
// src/components/__tests__/Counter.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from '../Counter.vue'
describe('Counter', () => {
it('renders default initial value', () => {
const wrapper = mount(Counter)
expect(wrapper.find('[data-testid="count"]').text()).toBe('0')
})
it('renders provided initial value', () => {
const wrapper = mount(Counter, {
props: { initial: 10 }
})
expect(wrapper.find('[data-testid="count"]').text()).toBe('10')
})
it('increments when clicking +', async () => {
const wrapper = mount(Counter)
await wrapper.find('[data-testid="increment"]').trigger('click')
expect(wrapper.find('[data-testid="count"]').text()).toBe('1')
})
it('decrements when clicking -', async () => {
const wrapper = mount(Counter, { props: { initial: 5 } })
await wrapper.find('[data-testid="decrement"]').trigger('click')
expect(wrapper.find('[data-testid="count"]').text()).toBe('4')
})
})
mount vs shallowMount
mount: Renders the complete component with all children. Ideal for integration tests.shallowMount: Renders only the component, replacing children with stubs. Ideal for isolated unit tests.
import { mount, shallowMount } from '@vue/test-utils'
const full = mount(ParentComponent)
const shallow = shallowMount(ParentComponent)
When to use each? Use mount by default. Only use shallowMount when child components are expensive to render or when you want strict test isolation.
Testing Props
describe('UserCard', () => {
it('displays user name', () => {
const wrapper = mount(UserCard, {
props: {
name: 'Francisco Zapata',
email: 'francisco@example.com',
role: 'admin'
}
})
expect(wrapper.text()).toContain('Francisco Zapata')
expect(wrapper.text()).toContain('francisco@example.com')
})
it('applies special class for admin', () => {
const wrapper = mount(UserCard, {
props: { name: 'Test', email: 't@t.com', role: 'admin' }
})
expect(wrapper.find('.badge').classes()).toContain('badge-admin')
})
})
Testing Emits
<!-- SearchInput.vue -->
<script setup>
const emit = defineEmits(['search', 'clear'])
const query = ref('')
function handleSearch() {
emit('search', query.value)
}
</script>
describe('SearchInput', () => {
it('emits search event with query', async () => {
const wrapper = mount(SearchInput)
await wrapper.find('input').setValue('vue 3')
await wrapper.find('form').trigger('submit')
expect(wrapper.emitted('search')).toBeTruthy()
expect(wrapper.emitted('search')[0]).toEqual(['vue 3'])
})
it('emits clear event', async () => {
const wrapper = mount(SearchInput)
await wrapper.find('[data-testid="clear"]').trigger('click')
expect(wrapper.emitted()).toHaveProperty('clear')
})
})
Testing Slots
describe('Card', () => {
it('renders default slot content', () => {
const wrapper = mount(Card, {
slots: {
default: '<p>Body content</p>'
}
})
expect(wrapper.find('.card-body').html()).toContain('Body content')
})
it('renders named slots', () => {
const wrapper = mount(Card, {
slots: {
header: '<h2>My Title</h2>',
default: '<p>Body</p>',
footer: '<button>Action</button>'
}
})
expect(wrapper.find('.card-header').text()).toBe('My Title')
expect(wrapper.find('.card-footer button').exists()).toBe(true)
})
it('shows default title when no header slot', () => {
const wrapper = mount(Card)
expect(wrapper.find('.card-header').text()).toBe('Título por defecto')
})
it('hides footer when no footer slot', () => {
const wrapper = mount(Card)
expect(wrapper.find('.card-footer').exists()).toBe(false)
})
})
Testing Composables
Composables can be tested directly without mounting a component:
import { describe, it, expect } from 'vitest'
import { useCounter } from '../useCounter'
describe('useCounter', () => {
it('starts with default value 0', () => {
const { count } = useCounter()
expect(count.value).toBe(0)
})
it('starts with custom value', () => {
const { count } = useCounter(10)
expect(count.value).toBe(10)
})
it('increments correctly', () => {
const { count, increment } = useCounter()
increment()
increment()
expect(count.value).toBe(2)
})
it('resets to initial value', () => {
const { count, increment, reset } = useCounter(5)
increment()
increment()
reset()
expect(count.value).toBe(5)
})
})
For composables using lifecycle hooks, you need a wrapper component:
import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
function withSetup(composable) {
let result
const comp = defineComponent({
setup() {
result = composable()
return () => null
}
})
const wrapper = mount(comp)
return { result, wrapper }
}
it('returns window size', () => {
const { result } = withSetup(() => useWindowSize())
expect(result.width.value).toBeGreaterThan(0)
})
Mocking
Module Mocking
import { vi } from 'vitest'
vi.mock('@/services/api', () => ({
fetchUsers: vi.fn(() => Promise.resolve([
{ id: 1, name: 'Ana' },
{ id: 2, name: 'Luis' }
]))
}))
Fetch API Mocking
import { vi } from 'vitest'
beforeEach(() => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ data: 'test' })
})
)
})
afterEach(() => {
vi.restoreAllMocks()
})
Vue Router Mocking
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div>Home</div>' } }]
})
const wrapper = mount(Component, {
global: {
plugins: [router]
}
})
Testing with Coverage
Install the coverage provider:
npm install -D @vitest/coverage-v8
Run:
npx vitest run --coverage
Configure minimum thresholds in vite.config.ts:
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: ['node_modules/', 'src/**/*.test.ts'],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
}
Best Practices
1. Test behavior, not implementation: Verify what the user sees, not internal details.
2. Use data-testid: Avoid fragile CSS selectors that break with styling changes.
3. One main assertion per test: Each test should verify a single thing.
4. Use async/await: Always wait for DOM changes with await nextTick() or await wrapper.trigger().
5. Clean up between tests: Use beforeEach/afterEach for clean state.
Conclusion
Vitest and Vue Test Utils form a powerful team for testing Vue 3 applications. Vitest's speed, combined with its Jest API compatibility, makes the transition straightforward. Start by testing your critical composables and components, and gradually expand coverage.
Comments (0)
Leave a comment
Be the first to comment