
Testing Vue components has become significantly more streamlined with the introduction of Vitest, a blazing-fast unit testing framework powered by Vite. As Vue applications grow in complexity, having a robust testing strategy becomes crucial for maintaining code quality and preventing regressions. This comprehensive guide will walk you through the essential techniques, patterns, and best practices for effectively testing Vue components with Vitest.
Why Vitest for Vue Testing?
Vitest has quickly become the go-to testing framework for Vue applications, and for good reason. Built by the same team behind Vite, it offers native ESM support, TypeScript integration out of the box, and lightning-fast test execution. The framework shares the same configuration and plugin system as Vite, meaning if your Vue app runs with Vite, your tests will run with minimal additional configuration.
The performance benefits are substantial. Vitest uses worker threads to run tests in parallel, implements smart file watching, and provides near-instant hot module replacement (HMR) for tests. This means you can run your test suite continuously during development without the typical performance penalties associated with traditional testing frameworks.
Setting Up Vitest in Your Vue Project
Getting started with Vitest in a Vue project requires minimal configuration. First, install the necessary dependencies:
bash
npm install -D vitest @vue/test-utils @vitejs/plugin-vue happy-dom
Next, configure Vitest in your vite.config.js
:
javascript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'happy-dom',
setupFiles: './src/tests/setup.js',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/tests/',
]
}
}
})
Create a setup file to configure the testing environment:
javascript
// src/tests/setup.js
import { config } from '@vue/test-utils'
import { vi } from 'vitest'
// Configure Vue Test Utils globally
config.global.mocks = {
$t: (key) => key, // Mock i18n
}
// Setup global test utilities
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}))
Testing Component Rendering and Props
One of the fundamental aspects of component testing is verifying that components render correctly with different prop configurations. Let’s explore a practical example with a UserCard component:
vue
<!-- UserCard.vue -->
<template>
<div class="user-card" :class="{ 'user-card--premium': isPremium }">
<img
:src="user.avatar"
:alt="`${user.name}'s avatar`"
class="user-card__avatar"
>
<div class="user-card__info">
<h3 class="user-card__name">{{ user.name }}</h3>
<p class="user-card__email">{{ user.email }}</p>
<span v-if="isPremium" class="user-card__badge">Premium</span>
</div>
<button
@click="$emit('contact', user.id)"
class="user-card__button"
:disabled="isDisabled"
>
Contact User
</button>
</div>
</template>
<script setup>
defineProps({
user: {
type: Object,
required: true,
validator: (user) => {
return user.id && user.name && user.email
}
},
isPremium: {
type: Boolean,
default: false
},
isDisabled: {
type: Boolean,
default: false
}
})
defineEmits(['contact'])
</script>
Now, let’s write comprehensive tests for this component:
javascript
// UserCard.test.js
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import UserCard from './UserCard.vue'
describe('UserCard', () => {
const mockUser = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
avatar: 'https://example.com/avatar.jpg'
}
describe('Rendering', () => {
it('renders user information correctly', () => {
const wrapper = mount(UserCard, {
props: { user: mockUser }
})
expect(wrapper.find('.user-card__name').text()).toBe('John Doe')
expect(wrapper.find('.user-card__email').text()).toBe('john@example.com')
expect(wrapper.find('.user-card__avatar').attributes('src')).toBe(mockUser.avatar)
expect(wrapper.find('.user-card__avatar').attributes('alt')).toBe("John Doe's avatar")
})
it('displays premium badge when user is premium', () => {
const wrapper = mount(UserCard, {
props: {
user: mockUser,
isPremium: true
}
})
expect(wrapper.find('.user-card__badge').exists()).toBe(true)
expect(wrapper.find('.user-card__badge').text()).toBe('Premium')
expect(wrapper.classes()).toContain('user-card--premium')
})
it('does not display premium badge for regular users', () => {
const wrapper = mount(UserCard, {
props: { user: mockUser }
})
expect(wrapper.find('.user-card__badge').exists()).toBe(false)
expect(wrapper.classes()).not.toContain('user-card--premium')
})
})
describe('Interactions', () => {
it('emits contact event with user id when button is clicked', async () => {
const wrapper = mount(UserCard, {
props: { user: mockUser }
})
await wrapper.find('.user-card__button').trigger('click')
expect(wrapper.emitted()).toHaveProperty('contact')
expect(wrapper.emitted('contact')[0]).toEqual([1])
})
it('disables contact button when isDisabled prop is true', () => {
const wrapper = mount(UserCard, {
props: {
user: mockUser,
isDisabled: true
}
})
expect(wrapper.find('.user-card__button').attributes('disabled')).toBeDefined()
})
})
describe('Props Validation', () => {
it('validates user prop structure', () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const invalidUser = { name: 'John' } // Missing required fields
mount(UserCard, {
props: { user: invalidUser }
})
expect(consoleWarnSpy).toHaveBeenCalled()
consoleWarnSpy.mockRestore()
})
})
})
Testing Composables and Reactive State
Vue 3’s Composition API introduces composables, which require specific testing strategies. Let’s create a composable for managing a shopping cart and test it thoroughly:
javascript
// useShoppingCart.js
import { ref, computed } from 'vue'
export function useShoppingCart() {
const items = ref([])
const discount = ref(0)
const addItem = (product) => {
const existingItem = items.value.find(item => item.id === product.id)
if (existingItem) {
existingItem.quantity += 1
} else {
items.value.push({
...product,
quantity: 1
})
}
}
const removeItem = (productId) => {
const index = items.value.findIndex(item => item.id === productId)
if (index > -1) {
items.value.splice(index, 1)
}
}
const updateQuantity = (productId, quantity) => {
const item = items.value.find(item => item.id === productId)
if (item) {
if (quantity <= 0) {
removeItem(productId)
} else {
item.quantity = quantity
}
}
}
const subtotal = computed(() => {
return items.value.reduce((total, item) => {
return total + (item.price * item.quantity)
}, 0)
})
const total = computed(() => {
const discountAmount = subtotal.value * (discount.value / 100)
return Math.max(0, subtotal.value - discountAmount)
})
const itemCount = computed(() => {
return items.value.reduce((count, item) => count + item.quantity, 0)
})
const applyDiscount = (percentage) => {
discount.value = Math.min(100, Math.max(0, percentage))
}
const clearCart = () => {
items.value = []
discount.value = 0
}
return {
items,
discount,
addItem,
removeItem,
updateQuantity,
subtotal,
total,
itemCount,
applyDiscount,
clearCart
}
}
Testing this composable requires careful attention to reactivity:
javascript
// useShoppingCart.test.js
import { describe, it, expect, beforeEach } from 'vitest'
import { useShoppingCart } from './useShoppingCart'
describe('useShoppingCart', () => {
let cart
beforeEach(() => {
cart = useShoppingCart()
})
describe('Adding Items', () => {
it('adds new item to cart', () => {
const product = { id: 1, name: 'Laptop', price: 999 }
cart.addItem(product)
expect(cart.items.value).toHaveLength(1)
expect(cart.items.value[0]).toEqual({
...product,
quantity: 1
})
})
it('increments quantity when adding existing item', () => {
const product = { id: 1, name: 'Laptop', price: 999 }
cart.addItem(product)
cart.addItem(product)
expect(cart.items.value).toHaveLength(1)
expect(cart.items.value[0].quantity).toBe(2)
})
})
describe('Removing Items', () => {
it('removes item from cart', () => {
const product1 = { id: 1, name: 'Laptop', price: 999 }
const product2 = { id: 2, name: 'Mouse', price: 29 }
cart.addItem(product1)
cart.addItem(product2)
cart.removeItem(1)
expect(cart.items.value).toHaveLength(1)
expect(cart.items.value[0].id).toBe(2)
})
it('handles removing non-existent item gracefully', () => {
expect(() => cart.removeItem(999)).not.toThrow()
})
})
describe('Updating Quantities', () => {
it('updates item quantity', () => {
const product = { id: 1, name: 'Laptop', price: 999 }
cart.addItem(product)
cart.updateQuantity(1, 5)
expect(cart.items.value[0].quantity).toBe(5)
})
it('removes item when quantity is set to 0', () => {
const product = { id: 1, name: 'Laptop', price: 999 }
cart.addItem(product)
cart.updateQuantity(1, 0)
expect(cart.items.value).toHaveLength(0)
})
})
describe('Calculations', () => {
beforeEach(() => {
cart.addItem({ id: 1, name: 'Laptop', price: 1000 })
cart.addItem({ id: 2, name: 'Mouse', price: 50 })
cart.updateQuantity(2, 2) // 2 mice
})
it('calculates subtotal correctly', () => {
expect(cart.subtotal.value).toBe(1100) // 1000 + (50 * 2)
})
it('calculates item count correctly', () => {
expect(cart.itemCount.value).toBe(3) // 1 laptop + 2 mice
})
it('applies discount correctly', () => {
cart.applyDiscount(10)
expect(cart.discount.value).toBe(10)
expect(cart.total.value).toBe(990) // 1100 - 10%
})
it('prevents negative totals', () => {
cart.applyDiscount(150) // Tries to apply 150% discount
expect(cart.discount.value).toBe(100)
expect(cart.total.value).toBe(0)
})
})
describe('Clear Cart', () => {
it('removes all items and resets discount', () => {
cart.addItem({ id: 1, name: 'Laptop', price: 999 })
cart.applyDiscount(20)
cart.clearCart()
expect(cart.items.value).toHaveLength(0)
expect(cart.discount.value).toBe(0)
expect(cart.total.value).toBe(0)
})
})
})
Testing Async Operations and API Calls
Testing components that make API calls requires mocking external dependencies and handling asynchronous behavior. Here’s a comprehensive example:
vue
<!-- ProductList.vue -->
<template>
<div class="product-list">
<div v-if="loading" class="loading">Loading products...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else class="products">
<div
v-for="product in filteredProducts"
:key="product.id"
class="product"
>
<h3>{{ product.name }}</h3>
<p>${{ product.price }}</p>
<button @click="addToCart(product)">Add to Cart</button>
</div>
</div>
<div class="filters">
<input
v-model="searchQuery"
placeholder="Search products..."
@input="debouncedSearch"
>
<select v-model="sortBy" @change="sortProducts">
<option value="name">Name</option>
<option value="price">Price</option>
</select>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { debounce } from 'lodash-es'
import { useProductsApi } from './useProductsApi'
const { fetchProducts } = useProductsApi()
const products = ref([])
const loading = ref(false)
const error = ref(null)
const searchQuery = ref('')
const sortBy = ref('name')
const filteredProducts = computed(() => {
let filtered = products.value.filter(product =>
product.name.toLowerCase().includes(searchQuery.value.toLowerCase())
)
return filtered.sort((a, b) => {
if (sortBy.value === 'name') {
return a.name.localeCompare(b.name)
}
return a.price - b.price
})
})
const loadProducts = async () => {
loading.value = true
error.value = null
try {
const data = await fetchProducts()
products.value = data
} catch (err) {
error.value = err.message || 'Failed to load products'
} finally {
loading.value = false
}
}
const debouncedSearch = debounce((value) => {
searchQuery.value = value
}, 300)
const addToCart = (product) => {
// Implementation would emit event or call cart API
console.log('Adding to cart:', product)
}
onMounted(() => {
loadProducts()
})
</script>
Now let’s test this component with mocked API calls:
javascript
// ProductList.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { nextTick } from 'vue'
import ProductList from './ProductList.vue'
import * as productsApi from './useProductsApi'
vi.mock('./useProductsApi')
describe('ProductList', () => {
let mockFetchProducts
beforeEach(() => {
mockFetchProducts = vi.fn()
productsApi.useProductsApi = vi.fn(() => ({
fetchProducts: mockFetchProducts
}))
vi.useFakeTimers()
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
})
describe('Loading States', () => {
it('shows loading state while fetching products', async () => {
mockFetchProducts.mockImplementation(() =>
new Promise(resolve => setTimeout(resolve, 1000))
)
const wrapper = mount(ProductList)
expect(wrapper.find('.loading').exists()).toBe(true)
expect(wrapper.find('.products').exists()).toBe(false)
})
it('shows error state when fetch fails', async () => {
const errorMessage = 'Network error'
mockFetchProducts.mockRejectedValue(new Error(errorMessage))
const wrapper = mount(ProductList)
await flushPromises()
expect(wrapper.find('.error').exists()).toBe(true)
expect(wrapper.find('.error').text()).toBe(errorMessage)
expect(wrapper.find('.products').exists()).toBe(false)
})
it('shows products when fetch succeeds', async () => {
const mockProducts = [
{ id: 1, name: 'Laptop', price: 999 },
{ id: 2, name: 'Mouse', price: 29 }
]
mockFetchProducts.mockResolvedValue(mockProducts)
const wrapper = mount(ProductList)
await flushPromises()
expect(wrapper.find('.loading').exists()).toBe(false)
expect(wrapper.find('.error').exists()).toBe(false)
expect(wrapper.findAll('.product')).toHaveLength(2)
})
})
describe('Filtering and Sorting', () => {
let wrapper
beforeEach(async () => {
const mockProducts = [
{ id: 1, name: 'Gaming Laptop', price: 1500 },
{ id: 2, name: 'Office Laptop', price: 800 },
{ id: 3, name: 'Gaming Mouse', price: 75 },
{ id: 4, name: 'Wireless Mouse', price: 25 }
]
mockFetchProducts.mockResolvedValue(mockProducts)
wrapper = mount(ProductList)
await flushPromises()
})
it('filters products based on search query', async () => {
const searchInput = wrapper.find('input')
await searchInput.setValue('Gaming')
vi.advanceTimersByTime(300) // Advance past debounce
await nextTick()
const products = wrapper.findAll('.product')
expect(products).toHaveLength(2)
expect(products[0].text()).toContain('Gaming Laptop')
expect(products[1].text()).toContain('Gaming Mouse')
})
it('debounces search input', async () => {
const searchInput = wrapper.find('input')
await searchInput.setValue('G')
await searchInput.setValue('Ga')
await searchInput.setValue('Gam')
// Products should not be filtered yet
expect(wrapper.findAll('.product')).toHaveLength(4)
vi.advanceTimersByTime(300)
await nextTick()
// Now products should be filtered
expect(wrapper.findAll('.product')).toHaveLength(2)
})
it('sorts products by price', async () => {
const sortSelect = wrapper.find('select')
await sortSelect.setValue('price')
await nextTick()
const products = wrapper.findAll('.product')
expect(products[0].text()).toContain('$25') // Wireless Mouse
expect(products[1].text()).toContain('$75') // Gaming Mouse
expect(products[2].text()).toContain('$800') // Office Laptop
expect(products[3].text()).toContain('$1500') // Gaming Laptop
})
it('sorts products by name', async () => {
const sortSelect = wrapper.find('select')
await sortSelect.setValue('name')
await nextTick()
const products = wrapper.findAll('.product')
expect(products[0].text()).toContain('Gaming Laptop')
expect(products[1].text()).toContain('Gaming Mouse')
expect(products[2].text()).toContain('Office Laptop')
expect(products[3].text()).toContain('Wireless Mouse')
})
})
describe('User Interactions', () => {
it('adds product to cart when button is clicked', async () => {
const mockProducts = [
{ id: 1, name: 'Laptop', price: 999 }
]
mockFetchProducts.mockResolvedValue(mockProducts)
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const wrapper = mount(ProductList)
await flushPromises()
await wrapper.find('button').trigger('click')
expect(consoleLogSpy).toHaveBeenCalledWith(
'Adding to cart:',
mockProducts[0]
)
consoleLogSpy.mockRestore()
})
})
})
Testing Vuex Store Integration
When testing components that interact with Vuex stores, you need to mock the store appropriately. Here’s an example of testing a component with Vuex integration:
javascript
// store/modules/auth.js
export default {
namespaced: true,
state: () => ({
user: null,
isAuthenticated: false,
permissions: []
}),
getters: {
hasPermission: (state) => (permission) => {
return state.permissions.includes(permission)
},
userFullName: (state) => {
if (!state.user) return ''
return `${state.user.firstName} ${state.user.lastName}`
}
},
mutations: {
SET_USER(state, user) {
state.user = user
state.isAuthenticated = !!user
},
SET_PERMISSIONS(state, permissions) {
state.permissions = permissions
}
},
actions: {
async login({ commit }, credentials) {
try {
const response = await api.login(credentials)
commit('SET_USER', response.user)
commit('SET_PERMISSIONS', response.permissions)
return response
} catch (error) {
throw new Error('Login failed')
}
},
logout({ commit }) {
commit('SET_USER', null)
commit('SET_PERMISSIONS', [])
}
}
}
Testing a component that uses this store:
vue
<!-- UserProfile.vue -->
<template>
<div class="user-profile">
<div v-if="isAuthenticated">
<h2>{{ userFullName }}</h2>
<button
v-if="canEdit"
@click="editProfile"
class="edit-button"
>
Edit Profile
</button>
<button @click="handleLogout" class="logout-button">
Logout
</button>
</div>
<div v-else>
<p>Please log in to view your profile</p>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useStore } from 'vuex'
import { useRouter } from 'vue-router'
const store = useStore()
const router = useRouter()
const isAuthenticated = computed(() => store.state.auth.isAuthenticated)
const userFullName = computed(() => store.getters['auth/userFullName'])
const canEdit = computed(() => store.getters['auth/hasPermission']('edit_profile'))
const editProfile = () => {
router.push('/profile/edit')
}
const handleLogout = async () => {
await store.dispatch('auth/logout')
router.push('/login')
}
</script>
javascript
// UserProfile.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createStore } from 'vuex'
import UserProfile from './UserProfile.vue'
const mockPush = vi.fn()
vi.mock('vue-router', () => ({
useRouter: () => ({
push: mockPush
})
}))
describe('UserProfile', () => {
let store
let authModule
beforeEach(() => {
mockPush.mockClear()
authModule = {
namespaced: true,
state: () => ({
user: null,
isAuthenticated: false,
permissions: []
}),
getters: {
hasPermission: (state) => (permission) => {
return state.permissions.includes(permission)
},
userFullName: (state) => {
if (!state.user) return ''
return `${state.user.firstName} ${state.user.lastName}`
}
},
mutations: {
SET_USER(state, user) {
state.user = user
state.isAuthenticated = !!user
},
SET_PERMISSIONS(state, permissions) {
state.permissions = permissions
}
},
actions: {
logout: vi.fn(({ commit }) => {
commit('SET_USER', null)
commit('SET_PERMISSIONS', [])
})
}
}
store = createStore({
modules: {
auth: authModule
}
})
})
describe('Unauthenticated State', () => {
it('shows login message when not authenticated', () => {
const wrapper = mount(UserProfile, {
global: {
plugins: [store]
}
})
expect(wrapper.text()).toContain('Please log in to view your profile')
expect(wrapper.find('.edit-button').exists()).toBe(false)
expect(wrapper.find('.logout-button').exists()).toBe(false)
})
})
describe('Authenticated State', () => {
beforeEach(() => {
store.commit('auth/SET_USER', {
firstName: 'John',
lastName: 'Doe'
})
})
it('displays user full name when authenticated', () => {
const wrapper = mount(UserProfile, {
global: {
plugins: [store]
}
})
expect(wrapper.find('h2').text()).toBe('John Doe')
})
it('shows edit button when user has edit permission', () => {
store.commit('auth/SET_PERMISSIONS', ['edit_profile'])
const wrapper = mount(UserProfile, {
global: {
plugins: [store]
}
})
expect(wrapper.find('.edit-button').exists()).toBe(true)
})
it('hides edit button when user lacks edit permission', () => {
store.commit('auth/SET_PERMISSIONS', ['view_profile'])
const wrapper = mount(UserProfile, {
global: {
plugins: [store]
}
})
expect(wrapper.find('.edit-button').exists()).toBe(false)
})
it('navigates to edit page when edit button is clicked', async () => {
store.commit('auth/SET_PERMISSIONS', ['edit_profile'])
const wrapper = mount(UserProfile, {
global: {
plugins: [store]
}
})
await wrapper.find('.edit-button').trigger('click')
expect(mockPush).toHaveBeenCalledWith('/profile/edit')
})
it('logs out and redirects when logout button is clicked', async () => {
const wrapper = mount(UserProfile, {
global: {
plugins: [store]
}
})
await wrapper.find('.logout-button').trigger('click')
expect(authModule.actions.logout).toHaveBeenCalled()
expect(store.state.auth.isAuthenticated).toBe(false)
expect(mockPush).toHaveBeenCalledWith('/login')
})
})
})
Testing Router Navigation
Testing components that interact with Vue Router requires careful mocking of navigation guards and route parameters:
javascript
// RouteGuardExample.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import ProtectedRoute from './ProtectedRoute.vue'
describe('ProtectedRoute with Navigation Guards', () => {
let router
beforeEach(() => {
const routes = [
{
path: '/',
component: { template: '<div>Home</div>' }
},
{
path: '/protected',
component: ProtectedRoute,
meta: { requiresAuth: true },
beforeEnter: (to, from, next) => {
const isAuthenticated = localStorage.getItem('token')
if (isAuthenticated) {
next()
} else {
next('/login')
}
}
},
{
path: '/login',
component: { template: '<div>Login</div>' }
}
]
router = createRouter({
history: createMemoryHistory(),
routes
})
})
it('allows navigation to protected route when authenticated', async () => {
localStorage.setItem('token', 'fake-token')
await router.push('/protected')
await router.isReady()
expect(router.currentRoute.value.path).toBe('/protected')
localStorage.removeItem('token')
})
it('redirects to login when not authenticated', async () => {
localStorage.removeItem('token')
await router.push('/protected')
await router.isReady()
expect(router.currentRoute.value.path).toBe('/login')
})
it('handles route params and query strings', async () => {
const wrapper = mount({
template: '<div>{{ $route.params.id }} - {{ $route.query.filter }}</div>'
}, {
global: {
plugins: [router]
}
})
await router.push({
path: '/products/123',
query: { filter: 'electronics' }
})
expect(wrapper.text()).toContain('123')
expect(wrapper.text()).toContain('electronics')
})
})
Advanced Testing Patterns
Testing Custom Directives
Custom directives require special attention during testing:
javascript
// directives/v-click-outside.js
export default {
mounted(el, binding) {
el.clickOutsideEvent = (event) => {
if (!(el === event.target || el.contains(event.target))) {
binding.value(event)
}
}
document.addEventListener('click', el.clickOutsideEvent)
},
unmounted(el) {
document.removeEventListener('click', el.clickOutsideEvent)
}
}
javascript
// v-click-outside.test.js
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import vClickOutside from './v-click-outside'
describe('v-click-outside directive', () => {
it('triggers handler when clicking outside element', async () => {
const handler = vi.fn()
const wrapper = mount({
template: '<div v-click-outside="handler" class="target">Click outside me</div>',
methods: { handler },
directives: { clickOutside: vClickOutside }
})
// Click outside the element
document.body.click()
expect(handler).toHaveBeenCalled()
})
it('does not trigger when clicking inside element', async () => {
const handler = vi.fn()
const wrapper = mount({
template: '<div v-click-outside="handler" class="target">Click me</div>',
methods: { handler },
directives: { clickOutside: vClickOutside }
})
// Click inside the element
await wrapper.find('.target').trigger('click')
expect(handler).not.toHaveBeenCalled()
})
})
Testing Provide/Inject
Testing components that use provide/inject requires setting up the proper context:
javascript
// ThemeProvider.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { reactive } from 'vue'
const ThemeProvider = {
setup() {
const theme = reactive({
color: 'blue',
mode: 'light'
})
provide('theme', theme)
return { theme }
},
template: '<div><slot /></div>'
}
const ThemeConsumer = {
setup() {
const theme = inject('theme')
return { theme }
},
template: '<div class="consumer">{{ theme.color }} - {{ theme.mode }}</div>'
}
describe('Provide/Inject Pattern', () => {
it('provides theme data to child components', () => {
const wrapper = mount(ThemeProvider, {
slots: {
default: ThemeConsumer
}
})
expect(wrapper.find('.consumer').text()).toBe('blue - light')
})
it('updates child when provided data changes', async () => {
const wrapper = mount(ThemeProvider, {
slots: {
default: ThemeConsumer
}
})
wrapper.vm.theme.color = 'red'
wrapper.vm.theme.mode = 'dark'
await wrapper.vm.$nextTick()
expect(wrapper.find('.consumer').text()).toBe('red - dark')
})
})
Performance Testing
Vitest provides excellent tools for performance testing of Vue components:
javascript
// PerformanceTest.test.js
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import LargeList from './LargeList.vue'
describe('Performance Tests', () => {
it('renders large lists efficiently', () => {
const items = Array.from({ length: 1000 }, (_, i) => ({
id: i,
name: `Item ${i}`
}))
const startTime = performance.now()
const wrapper = mount(LargeList, {
props: { items }
})
const endTime = performance.now()
const renderTime = endTime - startTime
expect(renderTime).toBeLessThan(100) // Should render in less than 100ms
expect(wrapper.findAll('.list-item')).toHaveLength(1000)
})
it('memoizes expensive computations', () => {
const expensiveComputation = vi.fn((n) => {
// Simulate expensive operation
return n * n
})
const wrapper = mount({
props: ['value'],
setup(props) {
const squared = computed(() => expensiveComputation(props.value))
return { squared }
},
template: '<div>{{ squared }}</div>'
}, {
props: { value: 5 }
})
// Access computed multiple times
wrapper.vm.squared
wrapper.vm.squared
wrapper.vm.squared
expect(expensiveComputation).toHaveBeenCalledTimes(1)
})
})
Testing Error Boundaries and Error Handling
Vue 3’s error handling mechanisms need thorough testing:
javascript
// ErrorBoundary.vue
<template>
<div class="error-boundary">
<div v-if="hasError" class="error-display">
<h2>Something went wrong</h2>
<p>{{ errorMessage }}</p>
<button @click="reset">Try Again</button>
</div>
<slot v-else />
</div>
</template>
<script setup>
import { ref, onErrorCaptured } from 'vue'
const hasError = ref(false)
const errorMessage = ref('')
onErrorCaptured((error) => {
hasError.value = true
errorMessage.value = error.message
console.error('Error captured:', error)
return false // Prevent error from propagating
})
const reset = () => {
hasError.value = false
errorMessage.value = ''
}
</script>
javascript
// ErrorBoundary.test.js
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import ErrorBoundary from './ErrorBoundary.vue'
const BrokenComponent = {
setup() {
throw new Error('Component failed to render')
},
template: '<div>This will not render</div>'
}
const WorkingComponent = {
template: '<div class="working">Working component</div>'
}
describe('ErrorBoundary', () => {
it('renders slot content when no error occurs', () => {
const wrapper = mount(ErrorBoundary, {
slots: {
default: WorkingComponent
}
})
expect(wrapper.find('.working').exists()).toBe(true)
expect(wrapper.find('.error-display').exists()).toBe(false)
})
it('catches and displays errors from child components', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const wrapper = mount(ErrorBoundary, {
slots: {
default: BrokenComponent
}
})
expect(wrapper.find('.error-display').exists()).toBe(true)
expect(wrapper.text()).toContain('Something went wrong')
expect(wrapper.text()).toContain('Component failed to render')
consoleErrorSpy.mockRestore()
})
it('resets error state when try again is clicked', async () => {
const wrapper = mount(ErrorBoundary, {
slots: {
default: BrokenComponent
}
})
expect(wrapper.find('.error-display').exists()).toBe(true)
// Mock the component to work after reset
wrapper.vm.$slots.default = () => WorkingComponent
await wrapper.find('button').trigger('click')
expect(wrapper.find('.error-display').exists()).toBe(false)
})
})
Best Practices and Tips
1. Test Structure and Organization
Organize your tests following a clear structure that mirrors your component hierarchy. Use descriptive test names that explain what is being tested and what the expected outcome is. Group related tests using describe blocks for better organization and readability.
2. Mock External Dependencies Wisely
Always mock external dependencies like API calls, timers, and browser APIs. This ensures your tests are deterministic and run quickly. However, avoid over-mocking – test the actual component logic rather than your mocks.
3. Test User Behavior, Not Implementation
Focus on testing what users see and do rather than internal implementation details. Test public APIs, emitted events, and rendered output rather than private methods or internal state.
4. Use Data Attributes for Test Selectors
Instead of relying on CSS classes or component structure for selecting elements in tests, use dedicated data attributes:
vue
<button data-test="submit-button" class="btn btn-primary">Submit</button>
javascript
const submitButton = wrapper.find('[data-test="submit-button"]')
5. Keep Tests DRY with Helper Functions
Create helper functions for common testing patterns:
javascript
// test-utils.js
export function createWrapper(component, options = {}) {
return mount(component, {
global: {
plugins: [router, store],
mocks: {
$t: (key) => key
},
...options.global
},
...options
})
}
export function waitForAsync() {
return flushPromises().then(() => nextTick())
}
6. Test Accessibility
Include accessibility testing in your component tests:
javascript
it('has proper ARIA attributes', () => {
const wrapper = mount(Modal, {
props: { isOpen: true }
})
expect(wrapper.attributes('role')).toBe('dialog')
expect(wrapper.attributes('aria-modal')).toBe('true')
expect(wrapper.find('.close-button').attributes('aria-label')).toBe('Close modal')
})
7. Snapshot Testing for Complex Renders
Use snapshot testing judiciously for complex component structures:
javascript
it('matches snapshot for complex layout', () => {
const wrapper = mount(ComplexDashboard, {
props: { userData: mockUserData }
})
expect(wrapper.html()).toMatchSnapshot()
})
8. Coverage Goals
Aim for meaningful coverage rather than 100% coverage. Focus on critical paths, edge cases, and error scenarios. A well-tested codebase typically has 70-85% coverage with critical features having near 100% coverage.
Conclusion
Testing Vue components with Vitest provides a powerful and efficient way to ensure your application’s reliability and maintainability. By following the patterns and practices outlined in this guide, you can build a comprehensive test suite that catches bugs early, documents component behavior, and enables confident refactoring.
Remember that good tests are an investment in your codebase’s future. They serve as living documentation, catch regressions before they reach production, and enable your team to move faster with confidence. Start with testing critical user paths, gradually expand coverage to edge cases, and always write tests that would catch the bugs you’ve encountered in production.
The combination of Vue 3’s composition API and Vitest’s modern testing capabilities provides an excellent foundation for building robust, well-tested applications. As you continue to develop your testing skills, you’ll find that writing tests becomes second nature and an integral part of your development workflow.
Leave a Reply