Back to blog
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
Share:𝕏

Setup

Bash
npm install -D vitest @vue/test-utils @vitest/coverage-v8 jsdom
TYPESCRIPT
// 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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.