3 min read
On this page

Unit Testing with Vitest

Vitest is the testing framework for Vue projects. It uses the same configuration as Vite, which means zero separate bundler setup, near-instant test startup, and native ESM support. If your project runs on Vite (and every modern Vue/Nuxt project does), Vitest just works — same aliases, same transforms, same plugin pipeline.

Setup

Install Vitest and the Vue testing utilities:

npm install -D vitest @vue/test-utils happy-dom

Configure it in your vitest.config.ts or add a test block to vite.config.ts:

// vitest.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}.ts'],
  },
})

For Nuxt projects, use @nuxt/test-utils instead of configuring Vitest manually:

npm install -D @nuxt/test-utils vitest
// vitest.config.ts
import { defineVitestConfig } from '@nuxt/test-utils/config'

export default defineVitestConfig({
  test: {
    environment: 'nuxt',
  },
})

Add scripts to package.json:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage"
  }
}

Testing Pure Functions

Start with the simplest case: pure functions with no framework dependencies. These are the easiest to test and the most valuable per line of test code.

// src/utils/format.ts
export function formatPrice(cents: number, currency = 'USD'): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency,
  }).format(cents / 100)
}

export function slugify(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/(^-|-$)/g, '')
}
// src/utils/format.test.ts
import { describe, it, expect } from 'vitest'
import { formatPrice, slugify } from './format'

describe('formatPrice', () => {
  it('formats cents to a dollar string', () => {
    expect(formatPrice(1999)).toBe('$19.99')
  })

  it('handles zero', () => {
    expect(formatPrice(0)).toBe('$0.00')
  })

  it('supports other currencies', () => {
    expect(formatPrice(1500, 'EUR')).toBe('€15.00')
  })
})

describe('slugify', () => {
  it('converts a title to a URL slug', () => {
    expect(slugify('Hello World')).toBe('hello-world')
  })

  it('strips special characters', () => {
    expect(slugify('Vue 3: The Good Parts!')).toBe('vue-3-the-good-parts')
  })

  it('handles leading and trailing hyphens', () => {
    expect(slugify('--already-slugged--')).toBe('already-slugged')
  })
})

Run with npx vitest and you get watch mode by default. Tests re-run on file changes.

Testing Composables

Composables are where unit testing in Vue gets interesting. A composable that does not depend on a component lifecycle can be tested like a plain function:

// src/composables/useCounter.ts
export function useCounter(initial = 0) {
  const count = ref(initial)
  const increment = () => count.value++
  const decrement = () => count.value--
  const reset = () => (count.value = initial)

  return { count, increment, decrement, reset }
}
// src/composables/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('starts at the initial value', () => {
    const { count } = useCounter(10)
    expect(count.value).toBe(10)
  })

  it('increments', () => {
    const { count, increment } = useCounter()
    increment()
    increment()
    expect(count.value).toBe(2)
  })

  it('resets to the initial value', () => {
    const { count, increment, reset } = useCounter(5)
    increment()
    increment()
    reset()
    expect(count.value).toBe(5)
  })
})

Composables that use lifecycle hooks (onMounted, onUnmounted) or inject need a component context. Wrap them in a helper:

// test/helpers.ts
import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'

export function withSetup<T>(composable: () => T): T {
  let result: T
  mount(
    defineComponent({
      setup() {
        result = composable()
        return () => null
      },
    }),
  )
  return result!
}
// src/composables/useWindowSize.test.ts
import { describe, it, expect } from 'vitest'
import { withSetup } from '../test/helpers'
import { useWindowSize } from './useWindowSize'

describe('useWindowSize', () => {
  it('returns current window dimensions', () => {
    const { width, height } = withSetup(() => useWindowSize())
    expect(width.value).toBeGreaterThan(0)
    expect(height.value).toBeGreaterThan(0)
  })
})

Testing Pinia Stores

Pinia stores are composables under the hood. Use createTestingPinia from @pinia/testing to test them in isolation.

npm install -D @pinia/testing
// src/stores/cart.ts
export const useCartStore = defineStore('cart', () => {
  const items = ref<Array<{ id: string; name: string; price: number; quantity: number }>>([])

  const total = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0),
  )

  function addItem(product: { id: string; name: string; price: number }) {
    const existing = items.value.find((i) => i.id === product.id)
    if (existing) {
      existing.quantity++
    } else {
      items.value.push({ ...product, quantity: 1 })
    }
  }

  function removeItem(id: string) {
    items.value = items.value.filter((i) => i.id !== id)
  }

  function clear() {
    items.value = []
  }

  return { items, total, addItem, removeItem, clear }
})
// src/stores/cart.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCartStore } from './cart'

describe('useCartStore', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('starts with an empty cart', () => {
    const cart = useCartStore()
    expect(cart.items).toHaveLength(0)
    expect(cart.total).toBe(0)
  })

  it('adds items to the cart', () => {
    const cart = useCartStore()
    cart.addItem({ id: '1', name: 'Widget', price: 999 })
    expect(cart.items).toHaveLength(1)
    expect(cart.total).toBe(999)
  })

  it('increments quantity for duplicate items', () => {
    const cart = useCartStore()
    cart.addItem({ id: '1', name: 'Widget', price: 999 })
    cart.addItem({ id: '1', name: 'Widget', price: 999 })
    expect(cart.items).toHaveLength(1)
    expect(cart.items[0].quantity).toBe(2)
    expect(cart.total).toBe(1998)
  })

  it('removes items', () => {
    const cart = useCartStore()
    cart.addItem({ id: '1', name: 'Widget', price: 999 })
    cart.addItem({ id: '2', name: 'Gadget', price: 1499 })
    cart.removeItem('1')
    expect(cart.items).toHaveLength(1)
    expect(cart.items[0].name).toBe('Gadget')
  })
})

Mocking

Vitest has built-in mocking. Use vi.mock for module-level mocks and vi.fn() for individual functions.

// src/services/api.ts
export async function fetchUser(id: string) {
  const response = await fetch(`/api/users/${id}`)
  if (!response.ok) throw new Error('User not found')
  return response.json()
}
// src/services/api.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fetchUser } from './api'

// Mock the global fetch
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)

describe('fetchUser', () => {
  beforeEach(() => {
    mockFetch.mockReset()
  })

  it('returns user data on success', async () => {
    mockFetch.mockResolvedValue({
      ok: true,
      json: () => Promise.resolve({ id: '1', name: 'Alice' }),
    })

    const user = await fetchUser('1')
    expect(user).toEqual({ id: '1', name: 'Alice' })
    expect(mockFetch).toHaveBeenCalledWith('/api/users/1')
  })

  it('throws on non-ok response', async () => {
    mockFetch.mockResolvedValue({ ok: false })
    await expect(fetchUser('999')).rejects.toThrow('User not found')
  })
})

Mocking Modules

// Mock an entire module
vi.mock('~/services/analytics', () => ({
  trackEvent: vi.fn(),
  trackPageView: vi.fn(),
}))

// Now imports from ~/services/analytics return mocks
import { trackEvent } from '~/services/analytics'

it('tracks the event', () => {
  doSomething()
  expect(trackEvent).toHaveBeenCalledWith('button_click', { id: 'cta' })
})

Mocking Timers

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'

describe('debounce', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })

  afterEach(() => {
    vi.restoreAllTimers()
  })

  it('delays execution', () => {
    const fn = vi.fn()
    const debounced = debounce(fn, 300)

    debounced()
    expect(fn).not.toHaveBeenCalled()

    vi.advanceTimersByTime(300)
    expect(fn).toHaveBeenCalledOnce()
  })
})

Common Pitfalls

Testing implementation instead of behavior. If your test breaks when you refactor internals without changing the public API, the test is too tightly coupled. Test inputs and outputs, not how the code works internally.

Not resetting mocks between tests. Mocks accumulate call history across tests. Use beforeEach(() => vi.clearAllMocks()) or mockReset() to avoid flaky tests where order matters.

Forgetting setActivePinia in store tests. Without an active Pinia instance, defineStore throws. Always call setActivePinia(createPinia()) in beforeEach.

Over-mocking. If you mock everything a function depends on, you are testing the mocks, not the code. Mock external boundaries (HTTP, databases, third-party SDKs) and let internal logic run for real.

Ignoring TypeScript in tests. Type your mocks. If the API changes and your mock does not match the new interface, TypeScript catches it at compile time instead of you catching it at 2am in production.

Key Takeaways

  • Vitest shares Vite's config, so aliases, plugins, and transforms just work in tests.
  • Test pure functions and composables without component contexts when possible — they are fast and easy to maintain.
  • Use setActivePinia(createPinia()) in beforeEach for Pinia store tests.
  • Mock at the boundaries (fetch, external services) rather than mocking internal modules.
  • Run vitest in watch mode during development and vitest run in CI.