Advanced Vitest: Mocking, Snapshots, and Coverage Strategies
Prologue: 100% Coverage, But Still a Bug in Production
The coverage report proudly showed 100%. Then a bug hit production. How?
I found the cause later. I'd mocked an external API call, but the mock didn't match the actual API response shape. Coverage checks whether a line of code was executed — it doesn't guarantee the code behaves correctly in real-world conditions.
This post starts with that lesson. To use Vitest's powerful features properly, you need to know not just how to use each tool, but when to use it and when to avoid it.
What Is Mocking: The Actor Replacement Analogy
The best way to understand mocking is to think of film production.
When shooting an explosion scene, you don't use real explosives — they're dangerous, expensive, and can't be repeated. So the special effects team creates a safe, realistic-looking fake. The actors perform as if it's real. The result looks real.
Mocking in tests works the same way:
- Real DB calls → slow, data changes, tests become flaky
- Real external APIs → fail due to network issues, cost money
- Mocks → fast, predictable, work offline
Vitest has three primary mocking approaches: vi.mock(), vi.spyOn(), and vi.fn(). Each has a distinct role.
vi.fn(): The Simplest Fake Function
vi.fn() creates an empty shell function. You can track whether it was called and what it returned.
const mockCallback = vi.fn()
mockCallback('hello')
expect(mockCallback).toHaveBeenCalledWith('hello')
expect(mockCallback).toHaveBeenCalledTimes(1)
Control return values with mockReturnValue or mockResolvedValue:
const mockFetch = vi.fn()
// Sync return
mockFetch.mockReturnValue({ status: 200, data: 'ok' })
// Async return (Promise)
mockFetch.mockResolvedValue({ status: 200, data: 'ok' })
// Different value per call
mockFetch
.mockResolvedValueOnce({ status: 200, data: 'first' })
.mockResolvedValueOnce({ status: 429, data: 'rate limited' })
.mockResolvedValue({ status: 500, data: 'error' })
Use vi.fn() primarily for callbacks and event handlers:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from './Button'
test('calls onClick handler when button is clicked', async () => {
const handleClick = vi.fn()
render(<Button onClick={handleClick}>Click me</Button>)
await userEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
vi.spyOn(): Watching the Real Thing
vi.spyOn() observes a real function. It preserves the original behavior while tracking calls.
import * as utils from './utils'
test('formatDate is called correctly', () => {
const spy = vi.spyOn(utils, 'formatDate')
utils.formatDate(new Date('2026-01-01'))
expect(spy).toHaveBeenCalledTimes(1)
// The actual formatDate still runs
})
You can also override the implementation:
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
// console.error won't print during test
// but calls are still tracked
expect(consoleSpy).not.toHaveBeenCalled()
vi.fn() vs vi.spyOn() Comparison
| vi.fn() | vi.spyOn() |
|---|
| Target | Creates a new fake function | Observes an existing method |
| Original preserved | N/A (creates new) | Yes, by default |
| How to restore | Not needed | spy.mockRestore() |
| Main use | Callbacks, props | Module functions, globals |
vi.mock(): The Nuclear Option
vi.mock() replaces an entire module with a fake. Most powerful, but use with care.
// api.ts
export async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`)
return response.json()
}
// UserProfile.test.ts
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { fetchUser } from './api'
import UserProfile from './UserProfile'
vi.mock('./api')
describe('UserProfile', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('loads and displays user data', async () => {
const mockUser = { id: '1', name: 'Alice', email: 'alice@example.com' }
vi.mocked(fetchUser).mockResolvedValue(mockUser)
render(<UserProfile userId="1" />)
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument()
})
})
it('shows error message on failure', async () => {
vi.mocked(fetchUser).mockRejectedValue(new Error('Network Error'))
render(<UserProfile userId="1" />)
await waitFor(() => {
expect(screen.getByText('Failed to load data')).toBeInTheDocument()
})
})
})
The Hoisting Problem with vi.mock()
vi.mock() is hoisted to the top of the file. This is the most confusing part for beginners.
// Even if you write it here...
import { fetchUser } from './api'
vi.mock('./api')
// It actually executes like this:
vi.mock('./api') // runs first
import { fetchUser } from './api' // then this
That's why referencing outer variables inside the factory causes errors:
// WRONG — throws an error
const mockData = { name: 'Alice' }
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue(mockData) // mockData is not defined
}))
// CORRECT
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ name: 'Alice' })
}))
Partial Mocking
When you only want to mock part of a module, use importActual:
vi.mock('./utils', async (importActual) => {
const actual = await importActual<typeof import('./utils')>()
return {
...actual,
// Only mock formatDate, keep everything else real
formatDate: vi.fn().mockReturnValue('2026-01-01'),
}
})
Snapshot Testing: When It Helps, When It Hurts
Snapshot testing saves a component's rendered output to a file, then alerts you when it changes.
import { render } from '@testing-library/react'
import UserCard from './UserCard'
test('UserCard snapshot', () => {
const { container } = render(
<UserCard name="Alice" role="Frontend Developer" />
)
expect(container).toMatchSnapshot()
})
On the first run, it creates __snapshots__/UserCard.test.tsx.snap.
When snapshots are a good fit:
- UI regression prevention for stable components
- Complex serialization results (JSON transforms)
- Design system base components that rarely change
When snapshots become a trap:
// BAD — fails on every run because the date changes
test('date display snapshot', () => {
const { container } = render(<PostDate date={new Date()} />)
expect(container).toMatchSnapshot()
})
// GOOD — pin the date
test('date display snapshot', () => {
const fixedDate = new Date('2026-01-01T00:00:00.000Z')
const { container } = render(<PostDate date={fixedDate} />)
expect(container).toMatchSnapshot()
})
Snapshot Testing Checklist
| Situation | Good Fit? |
|---|
| Dynamic data (dates, random) | No |
| Rapidly changing UI | No (maintenance nightmare) |
| Third-party library components | No (breaks on every library upgrade) |
| Stable base components | Yes |
| Complex data transformation output | Yes |
Update snapshots with:
npx vitest run --update-snapshots
# or shorthand
npx vitest run -u
Coverage: Numbers Don't Tell the Whole Story
Coverage Configuration (vitest.config.ts)
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
coverage: {
provider: 'v8', // or 'istanbul'
reporter: ['text', 'lcov', 'html'],
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.d.ts',
'src/**/*.test.{ts,tsx}',
'src/**/*.stories.{ts,tsx}',
'src/types/**',
],
thresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
},
},
})
v8 vs istanbul
| v8 | istanbul |
|---|
| Speed | Faster | Slower |
| Accuracy | Lower (depends on source maps) | Higher |
| Setup | Built-in | Requires @vitest/coverage-istanbul |
| TypeScript | Source map based | Post-transpile analysis |
Use v8 as default; switch to istanbul when you need precise analysis.
Understanding Branch Coverage
function getDiscount(price: number, userType: 'vip' | 'regular' | 'new') {
if (userType === 'vip') {
return price * 0.3
} else if (userType === 'new') {
return price * 0.1
} else {
return 0
}
}
// With only this test:
test('vip discount', () => {
expect(getDiscount(100, 'vip')).toBe(30)
})
// Lines: 66%, Branches: 33%, Functions: 100%
Meaningful coverage strategy:
- Business logic first: discount calculations, validation → aim for 100%
- UI components: 60-70% is fine
- Utility functions: cover edge cases → 90%+
- External API wrappers: high coverage numbers mean little here
// Per-file thresholds in vitest.config.ts
export default defineConfig({
test: {
coverage: {
thresholds: {
'src/lib/utils.ts': {
lines: 90,
branches: 85,
},
'src/components/**': {
lines: 70,
},
},
},
},
})
Timer Mocking: Taking Control of Time
When testing setTimeout, setInterval, or Date, you can't wait for real time.
// debounce.test.ts
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
import { debounce } from './debounce'
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('executes function after the delay', () => {
const fn = vi.fn()
const debouncedFn = debounce(fn, 300)
debouncedFn()
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(300)
expect(fn).toHaveBeenCalledTimes(1)
})
it('only runs once after multiple rapid calls', () => {
const fn = vi.fn()
const debouncedFn = debounce(fn, 300)
debouncedFn()
debouncedFn()
debouncedFn()
vi.advanceTimersByTime(200)
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(100)
expect(fn).toHaveBeenCalledTimes(1)
})
})
Conclusion: Mocking Is a Tool, Not a Crutch
Over-mocking makes your tests tightly coupled to implementation details. Refactor the code and all your tests break — that's the sign of too much mocking.
The right mocking strategy in summary:
vi.fn() — for callbacks and event handlers
vi.spyOn() — to observe existing functions or partially override them
vi.mock() — to isolate external dependencies (APIs, DB, filesystem)
- Snapshots — for UI regression on stable components; avoid dynamic data
- Coverage — what paths you test matters more than the number
Testing is ultimately about confidence. Can you deploy and say "yes, tests passed, we're good"? If 100% coverage doesn't give you that confidence, the numbers are just numbers.