How to test Vue components with Vue Test Utils
Testing Vue components ensures they render correctly, handle user interactions, and emit events as expected. As the creator of CoreUI with over 10 years of Vue.js experience since 2014, I’ve written thousands of component tests to maintain code quality in production applications. The recommended approach uses Vue Test Utils for component mounting and interaction, combined with Vitest for running tests and assertions. This combination provides fast, reliable tests with excellent TypeScript support.
Install Vue Test Utils and Vitest to test Vue 3 components.
npm install --save-dev @vue/test-utils vitest
Vue Test Utils provides utilities for mounting components and interacting with them. Vitest is a fast test runner built specifically for Vite projects. These tools work together to provide a complete testing solution for Vue applications.
Writing a Basic Component Test
Test that a component renders correctly with props.
// Counter.vue
<template>
<div>
<p>Count: {{ count }}</p>
<button @click="increment">Increment</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
const increment = () => count.value++
</script>
// Counter.spec.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
describe('Counter', () => {
it('renders count value', () => {
const wrapper = mount(Counter)
expect(wrapper.text()).toContain('Count: 0')
})
it('increments count when button is clicked', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('Count: 1')
})
})
The mount() function creates a wrapper around the component. The wrapper.text() gets all text content. The trigger('click') simulates a button click. The await ensures Vue updates the DOM before assertions run. This pattern tests both rendering and interaction.
Testing Components with Props
Verify that components handle props correctly.
// UserCard.vue
<template>
<div class="user-card">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
</div>
</template>
<script setup>
defineProps({
user: {
type: Object,
required: true
}
})
</script>
// UserCard.spec.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import UserCard from './UserCard.vue'
describe('UserCard', () => {
const user = {
name: 'John Doe',
email: '[email protected]'
}
it('displays user name and email', () => {
const wrapper = mount(UserCard, {
props: { user }
})
expect(wrapper.text()).toContain('John Doe')
expect(wrapper.text()).toContain('[email protected]')
})
it('renders h3 with user name', () => {
const wrapper = mount(UserCard, {
props: { user }
})
const h3 = wrapper.find('h3')
expect(h3.text()).toBe('John Doe')
})
})
The props option passes props to the component. The wrapper.find() selects DOM elements using CSS selectors. This verifies that props are rendered correctly in the template.
Testing Event Emissions
Test that components emit events with correct payloads.
// SearchInput.vue
<template>
<input
type="text"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>
// SearchInput.spec.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import SearchInput from './SearchInput.vue'
describe('SearchInput', () => {
it('emits update:modelValue when input changes', async () => {
const wrapper = mount(SearchInput)
const input = wrapper.find('input')
await input.setValue('test query')
expect(wrapper.emitted()).toHaveProperty('update:modelValue')
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['test query'])
})
it('renders initial value from prop', () => {
const wrapper = mount(SearchInput, {
props: { modelValue: 'initial' }
})
expect(wrapper.find('input').element.value).toBe('initial')
})
})
The setValue() method triggers input changes. The wrapper.emitted() returns all emitted events. The array access [0] gets the first emission. This verifies v-model compatibility.
Testing Async Components
Test components that fetch data or perform async operations.
// UserList.vue
<template>
<div>
<p v-if="loading">Loading...</p>
<ul v-else>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const users = ref([])
const loading = ref(true)
onMounted(async () => {
const response = await fetch('/api/users')
users.value = await response.json()
loading.value = false
})
</script>
// UserList.spec.js
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import UserList from './UserList.vue'
describe('UserList', () => {
beforeEach(() => {
global.fetch = vi.fn()
})
it('displays loading state initially', () => {
const wrapper = mount(UserList)
expect(wrapper.text()).toContain('Loading...')
})
it('displays users after loading', async () => {
global.fetch.mockResolvedValueOnce({
json: async () => [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
]
})
const wrapper = mount(UserList)
await flushPromises()
expect(wrapper.text()).toContain('John')
expect(wrapper.text()).toContain('Jane')
expect(wrapper.text()).not.toContain('Loading...')
})
})
The vi.fn() creates a mock function. The mockResolvedValueOnce() returns a fake response. The flushPromises() waits for all pending promises to resolve. This allows testing async data fetching without real API calls.
Testing with Pinia Store
Test components that use Pinia for state management.
// UserProfile.vue
<template>
<div>
<p>{{ userStore.name }}</p>
<button @click="userStore.updateName('New Name')">Update</button>
</div>
</template>
<script setup>
import { useUserStore } from './stores/user'
const userStore = useUserStore()
</script>
// UserProfile.spec.js
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import UserProfile from './UserProfile.vue'
import { useUserStore } from './stores/user'
describe('UserProfile', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('displays user name from store', () => {
const userStore = useUserStore()
userStore.name = 'John Doe'
const wrapper = mount(UserProfile)
expect(wrapper.text()).toContain('John Doe')
})
it('updates name when button is clicked', async () => {
const wrapper = mount(UserProfile)
const userStore = useUserStore()
await wrapper.find('button').trigger('click')
expect(userStore.name).toBe('New Name')
})
})
The createPinia() creates a fresh store instance for each test. The setActivePinia() makes it the active instance. This ensures tests are isolated and don’t affect each other.
Best Practice Note
This is the same testing approach we use across CoreUI Vue components to ensure reliability and prevent regressions. Always test user-facing behavior rather than implementation details - test what users see and do, not internal component state. Use flushPromises() when testing async operations to ensure DOM updates complete. Mock external dependencies like API calls to make tests fast and predictable. For complex components, write separate test files for different features rather than one massive test suite. Consider using CoreUI Vue components which include comprehensive test coverage out of the box, providing working examples of well-tested component patterns.



