Frontend Engineeringintermediate
Testing Vue 3 with Vitest & Vue Test Utils
Write comprehensive tests for Vue 3 apps — unit test components with Vue Test Utils, test Pinia stores, mock composables and API calls with Vitest, test async behavior, and set up a CI test pipeline.
LearnixoApril 16, 20265 min read
Vue.jsVitestTestingVue Test UtilsPiniaTypeScriptFrontend
Setup
Bash
npm install -D vitest @vue/test-utils @vitest/coverage-v8 jsdomTYPESCRIPT
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true, // no need to import describe/it/expect
setupFiles: ['./src/test/setup.ts'],
coverage: {
reporter: ['text', 'html'],
exclude: ['node_modules/', 'src/test/'],
},
},
})TYPESCRIPT
// src/test/setup.ts
import { config } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
import { routes } from '@/router/routes'
// Global plugins applied to every mount()
config.global.plugins = [
createPinia(),
createRouter({ history: createMemoryHistory(), routes }),
]Testing Components
Basic Rendering
TYPESCRIPT
// src/components/__tests__/AppointmentCard.test.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import AppointmentCard from '@/components/AppointmentCard.vue'
import type { Appointment } from '@/types'
const mockAppointment: Appointment = {
id: 'APT-001',
clinicId: 'CLN-001',
clinicName: 'Sunrise Eye Care',
patientId: 'PAT-001',
patientName: 'Jane Doe',
dateTime: '2026-04-16T10:00:00Z',
status: 'scheduled',
type: 'routine',
notes: null,
createdAt: '2026-04-10T08:00:00Z',
}
describe('AppointmentCard', () => {
it('renders patient name and status', () => {
const wrapper = mount(AppointmentCard, {
props: { appointment: mockAppointment },
})
expect(wrapper.text()).toContain('Jane Doe')
expect(wrapper.text()).toContain('Upcoming') // "scheduled" → "Upcoming" label
})
it('applies highlighted class when isHighlighted=true', () => {
const wrapper = mount(AppointmentCard, {
props: { appointment: mockAppointment, isHighlighted: true },
})
expect(wrapper.classes()).toContain('ring-2')
})
it('does not apply highlighted class by default', () => {
const wrapper = mount(AppointmentCard, {
props: { appointment: mockAppointment },
})
expect(wrapper.classes()).not.toContain('ring-2')
})
})Testing User Interactions
TYPESCRIPT
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import AppointmentCard from '@/components/AppointmentCard.vue'
describe('AppointmentCard — events', () => {
it('emits select when card is clicked', async () => {
const wrapper = mount(AppointmentCard, {
props: { appointment: mockAppointment, selectable: true },
})
await wrapper.trigger('click')
expect(wrapper.emitted('select')).toHaveLength(1)
expect(wrapper.emitted('select')![0]).toEqual(['APT-001'])
})
it('emits cancel with reason when cancel button clicked', async () => {
const wrapper = mount(AppointmentCard, {
props: { appointment: mockAppointment },
})
await wrapper.find('[data-testid="cancel-btn"]').trigger('click')
await wrapper.find('[data-testid="cancel-reason"]').setValue('Patient request')
await wrapper.find('[data-testid="confirm-cancel"]').trigger('click')
expect(wrapper.emitted('cancel')![0]).toEqual(['APT-001', 'Patient request'])
})
})Testing v-model
TYPESCRIPT
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import SearchInput from '@/components/SearchInput.vue'
describe('SearchInput', () => {
it('emits update:modelValue on input', async () => {
const wrapper = mount(SearchInput, {
props: { modelValue: '' },
})
await wrapper.find('input').setValue('Jane')
expect(wrapper.emitted('update:modelValue')![0]).toEqual(['Jane'])
})
it('renders current modelValue', () => {
const wrapper = mount(SearchInput, {
props: { modelValue: 'existing search' },
})
expect((wrapper.find('input').element as HTMLInputElement).value)
.toBe('existing search')
})
})Testing with Pinia
TYPESCRIPT
// src/pages/__tests__/Dashboard.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { useAppointmentStore } from '@/stores/appointments'
import Dashboard from '@/pages/Dashboard.vue'
import * as api from '@/lib/api'
vi.mock('@/lib/api')
describe('Dashboard', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('shows loading state while fetching', async () => {
vi.mocked(api.getAppointments).mockReturnValue(new Promise(() => {})) // never resolves
const wrapper = mount(Dashboard)
expect(wrapper.find('[data-testid="loading-spinner"]').exists()).toBe(true)
})
it('renders appointments after fetch', async () => {
vi.mocked(api.getAppointments).mockResolvedValue([mockAppointment])
const wrapper = mount(Dashboard)
// Wait for async store action to complete
await flushPromises()
expect(wrapper.text()).toContain('Jane Doe')
expect(wrapper.find('[data-testid="loading-spinner"]').exists()).toBe(false)
})
it('shows error message on fetch failure', async () => {
vi.mocked(api.getAppointments).mockRejectedValue(new Error('Network error'))
const wrapper = mount(Dashboard)
await flushPromises()
expect(wrapper.find('[data-testid="error-message"]').text()).toContain('Network error')
})
it('triggers cancel action when cancel clicked', async () => {
vi.mocked(api.getAppointments).mockResolvedValue([mockAppointment])
vi.mocked(api.updateAppointmentStatus).mockResolvedValue(undefined as any)
const wrapper = mount(Dashboard)
await flushPromises()
const store = useAppointmentStore()
const cancelSpy = vi.spyOn(store, 'cancel')
await wrapper.find('[data-testid="cancel-btn"]').trigger('click')
await flushPromises()
expect(cancelSpy).toHaveBeenCalledWith('APT-001')
})
})Testing Composables
TYPESCRIPT
// src/composables/__tests__/useDebouncedSearch.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, nextTick } from 'vue'
import { useDebouncedSearch } from '@/composables/useDebouncedSearch'
// Wrap composable in a dummy component to use it with Vue's lifecycle
function useComposable<T>(setup: () => T): T {
let result!: T
mount(defineComponent({
setup() { result = setup(); return {} },
template: '<div/>',
}))
return result
}
describe('useDebouncedSearch', () => {
beforeEach(() => {
vi.useFakeTimers()
})
it('does not search on empty query', async () => {
const searcher = vi.fn().mockResolvedValue([])
const { query, results } = useComposable(() => useDebouncedSearch(searcher, 300))
query.value = ''
await nextTick()
vi.advanceTimersByTime(400)
expect(searcher).not.toHaveBeenCalled()
expect(results.value).toHaveLength(0)
})
it('searches after debounce delay', async () => {
const mockResults = [{ id: '1', name: 'Jane Doe' }]
const searcher = vi.fn().mockResolvedValue(mockResults)
const { query, results } = useComposable(() => useDebouncedSearch(searcher, 300))
query.value = 'Jane'
await nextTick()
expect(searcher).not.toHaveBeenCalled() // not yet
vi.advanceTimersByTime(300)
await nextTick()
expect(searcher).toHaveBeenCalledWith('Jane')
})
it('debounces rapid typing', async () => {
const searcher = vi.fn().mockResolvedValue([])
const { query } = useComposable(() => useDebouncedSearch(searcher, 300))
query.value = 'J'
await nextTick()
vi.advanceTimersByTime(100)
query.value = 'Ja'
await nextTick()
vi.advanceTimersByTime(100)
query.value = 'Jane'
await nextTick()
vi.advanceTimersByTime(300)
// Only one search for final value
expect(searcher).toHaveBeenCalledTimes(1)
expect(searcher).toHaveBeenCalledWith('Jane')
})
})Testing Router Navigation
TYPESCRIPT
import { describe, it, expect } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import App from '@/App.vue'
import { routes } from '@/router/routes'
describe('router guards', () => {
it('redirects unauthenticated users to /login', async () => {
const router = createRouter({
history: createMemoryHistory(),
routes,
})
const wrapper = mount(App, {
global: { plugins: [router] }
})
await router.push('/appointments')
await flushPromises()
expect(router.currentRoute.value.name).toBe('Login')
expect(router.currentRoute.value.query.redirect).toBe('/appointments')
})
})Snapshot Testing for UI
TYPESCRIPT
it('matches snapshot', () => {
const wrapper = mount(AppointmentCard, {
props: { appointment: mockAppointment },
})
expect(wrapper.html()).toMatchSnapshot()
})When to use snapshots: For stable UI components that shouldn't change unexpectedly. Update them with vitest --update-snapshots when intentional changes are made.
Coverage Configuration
JSON
// package.json
{
"scripts": {
"test": "vitest",
"test:coverage": "vitest --coverage",
"test:ui": "vitest --ui"
}
}Run npm run test:coverage to generate an HTML coverage report. Aim for >80% on stores and composables — less important for pure presentation components.
Enjoyed this article?
Explore the Frontend Engineering learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.