Mocking Strategies with vi
Why Mocking Matters (and Why It's Dangerous)
Mocking is the most powerful and most abused tool in a test suite. Used well, mocks isolate the thing you're testing from the things you're not. Used poorly, they turn your tests into a mirror of your implementation — tests that pass even when the code is broken.
Here's the core tension: every mock is a lie you tell your test. You're saying "pretend this thing works this way." The more lies you stack up, the less your test tells you about reality.
This tutorial covers every mocking strategy Vitest gives you through its vi namespace — and, just as importantly, when to skip mocking entirely.
Think of mocking like a stunt double in a movie. When the real actor (your dependency) is too expensive, dangerous, or unpredictable for a scene (your test), you bring in a stunt double. The double looks enough like the real thing to film the scene. But if you replace every actor with stunt doubles, you're not making a movie anymore — you're just filming stunt doubles talking to each other.
vi.fn() — Creating Function Mocks
vi.fn() creates a standalone mock function. It records every call, its arguments, return values, and the this context. This is your bread and butter.
import { vi, test, expect } from 'vitest'
const mockCallback = vi.fn()
mockCallback('hello')
mockCallback('world')
expect(mockCallback).toHaveBeenCalledTimes(2)
expect(mockCallback).toHaveBeenCalledWith('hello')
expect(mockCallback).toHaveBeenLastCalledWith('world')
Controlling Return Values
You can tell a mock function what to return:
const getPrice = vi.fn()
getPrice.mockReturnValue(99)
getPrice() // 99
getPrice() // 99
getPrice.mockReturnValueOnce(50).mockReturnValueOnce(75)
getPrice() // 50
getPrice() // 75
getPrice() // 99 (falls back to mockReturnValue)
Async Mocks
For functions that return promises:
const fetchUser = vi.fn()
fetchUser.mockResolvedValue({ id: 1, name: 'Alice' })
await fetchUser() // { id: 1, name: 'Alice' }
fetchUser.mockRejectedValueOnce(new Error('Network error'))
await fetchUser() // throws Error('Network error')
Custom Implementation
When you need logic, not just a static return:
const calculate = vi.fn((a: number, b: number) => a + b)
calculate(2, 3) // 5
expect(calculate).toHaveBeenCalledWith(2, 3)
calculate.mockImplementationOnce((a, b) => a * b)
calculate(2, 3) // 6 (multiplies this time)
calculate(2, 3) // 5 (back to original implementation)
vi.spyOn() — Spying on Existing Methods
vi.spyOn() wraps an existing method with mock functionality while keeping the original implementation intact by default. This is the key difference from vi.fn() — a spy observes without replacing.
import { vi, test, expect } from 'vitest'
const cart = {
items: [] as string[],
add(item: string) {
this.items.push(item)
return this.items.length
},
}
const spy = vi.spyOn(cart, 'add')
cart.add('shoes') // Actually runs — items now has ['shoes']
expect(spy).toHaveBeenCalledWith('shoes')
expect(cart.items).toEqual(['shoes']) // Real side effect happened
Replacing a Spy's Implementation
You can override the real behavior when needed:
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
console.log('this will not print')
expect(spy).toHaveBeenCalledWith('this will not print')
spy.mockRestore() // Restores original console.log
Spying on Getters and Setters
const user = {
_name: 'Alice',
get name() {
return this._name
},
set name(val: string) {
this._name = val
},
}
const getSpy = vi.spyOn(user, 'name', 'get')
getSpy.mockReturnValue('Bob')
user.name // 'Bob' (mocked getter)
vi.mock() — Module-Level Mocking
This is the big one. vi.mock() replaces an entire module import with mocked versions. Vitest hoists vi.mock() calls to the top of the file automatically, so they execute before any imports.
import { vi, test, expect } from 'vitest'
import { fetchUser } from './api'
import { UserProfile } from './UserProfile'
vi.mock('./api')
test('renders user name', async () => {
vi.mocked(fetchUser).mockResolvedValue({
id: 1,
name: 'Alice',
})
const result = await fetchUser(1)
expect(result.name).toBe('Alice')
})
When you call vi.mock('./api') without a factory, Vitest auto-mocks every export. Functions become vi.fn(), objects become empty objects, and classes become mocked constructors.
Factory Functions
For precise control, pass a factory:
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
fetchPosts: vi.fn().mockResolvedValue([]),
}))
The Hoisting Trap
Because vi.mock() is hoisted, you cannot reference variables from the outer scope inside the factory:
const mockUser = { id: 1, name: 'Alice' }
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue(mockUser),
}))
This fails because mockUser is not defined at the time vi.mock() runs (it's hoisted above the variable declaration). To fix this, use vi.hoisted():
const { mockUser } = vi.hoisted(() => ({
mockUser: { id: 1, name: 'Alice' },
}))
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue(mockUser),
}))
vi.mock() is hoisted to the top of the file. Variables declared in the normal flow are not available inside the mock factory. Use vi.hoisted() to declare values that need to be accessible in both the factory and your tests.
Partial Mocking with vi.importActual()
Sometimes you want to mock one export from a module but keep everything else real. This is partial mocking:
vi.mock('./utils', async () => {
const actual = await vi.importActual<typeof import('./utils')>('./utils')
return {
...actual,
generateId: vi.fn(() => 'test-id-123'),
}
})
Now every function from ./utils behaves normally except generateId, which returns a predictable value. This is massively useful for modules where most exports are pure functions you want to keep real, but one function is non-deterministic (random IDs, timestamps, etc.).
Manual Mocks with __mocks__
For mocks you reuse across many test files, create a __mocks__ directory next to the module:
src/
utils/
analytics.ts
__mocks__/
analytics.ts
The manual mock file:
// src/utils/__mocks__/analytics.ts
import { vi } from 'vitest'
export const track = vi.fn()
export const identify = vi.fn()
export const pageView = vi.fn()
Now any test file that calls vi.mock('./utils/analytics') (without a factory) will automatically use this manual mock instead of auto-mocking.
For node_modules mocking, place the __mocks__ directory at the project root:
__mocks__/
axios.ts // Mocks 'axios' in any test
src/
...
Manual mocks are perfect for modules like analytics, logging, or third-party SDKs that you always want to mock the same way.
Mocking ESM Modules
Vitest handles ESM natively, so most mocking works out of the box. But there are nuances when mocking modules that use export default:
vi.mock('./config', () => ({
default: {
apiUrl: 'https://test.api.com',
timeout: 5000,
},
}))
For named exports, it works exactly as you'd expect:
vi.mock('./constants', () => ({
API_URL: 'https://test.api.com',
MAX_RETRIES: 3,
}))
When mocking third-party ESM packages, you may need to be explicit about the module structure:
vi.mock('some-esm-package', () => ({
default: vi.fn(),
namedExport: vi.fn(),
}))
Timer Mocks — vi.useFakeTimers()
Timer mocks let you control setTimeout, setInterval, Date.now, and other time-dependent APIs. This is essential for testing debounce, throttle, polling, animations, and any time-based logic.
import { vi, test, expect, beforeEach, afterEach } from 'vitest'
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
test('debounce fires after delay', () => {
const callback = vi.fn()
const debounced = debounce(callback, 300)
debounced()
debounced()
debounced()
expect(callback).not.toHaveBeenCalled()
vi.advanceTimersByTime(300)
expect(callback).toHaveBeenCalledTimes(1)
})
Key Timer Methods
vi.advanceTimersByTime(1000) // Fast-forward 1 second
vi.advanceTimersToNextTimer() // Jump to next scheduled timer
vi.runAllTimers() // Run all pending timers (careful with intervals)
vi.runOnlyPendingTimers() // Run pending, but not newly scheduled timers
vi.getTimerCount() // How many timers are waiting
Controlling Date
vi.useFakeTimers() also mocks Date:
vi.setSystemTime(new Date('2025-01-15T10:00:00Z'))
new Date().toISOString() // '2025-01-15T10:00:00.000Z'
Date.now() // 1736935200000
Always pair vi.useFakeTimers() with vi.useRealTimers() in your cleanup. Leaked fake timers can cause bizarre failures in subsequent tests because setTimeout and Date.now stay mocked.
Mocking Globals — fetch, Date, Math.random
Mocking fetch
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ users: [] }),
})
test('fetches users', async () => {
const response = await fetch('/api/users')
const data = await response.json()
expect(data.users).toEqual([])
expect(mockFetch).toHaveBeenCalledWith('/api/users')
})
Mocking Math.random
const randomSpy = vi.spyOn(Math, 'random')
randomSpy.mockReturnValue(0.5)
Math.random() // Always 0.5
randomSpy.mockRestore()
Mocking Date Without Fake Timers
If you only need a fixed date without mocking timers:
const dateSpy = vi.spyOn(globalThis, 'Date').mockImplementation(
() => new Date('2025-06-15') as unknown as string
)
// Or use vi.useFakeTimers with specific options:
vi.useFakeTimers({ shouldAdvanceTime: true })
vi.setSystemTime(new Date('2025-06-15'))
Clearing, Resetting, and Restoring Mocks
This is where developers consistently get confused. There are three distinct operations:
| Method | What It Clears | When to Use |
|---|---|---|
| mockClear() | Call history, instances, results — but keeps implementation | Between tests that share a mock but need fresh call counts |
| mockReset() | Everything mockClear does + removes mock implementation (reverts to returning undefined) | When you want a clean slate between tests |
| mockRestore() | Everything mockReset does + restores original implementation (only works with vi.spyOn) | In afterEach when using spies — brings back real behavior |
| vi.clearAllMocks() | Runs mockClear() on every mock | In global beforeEach/afterEach for blanket cleanup |
| vi.resetAllMocks() | Runs mockReset() on every mock | When all tests need completely fresh mocks |
| vi.restoreAllMocks() | Runs mockRestore() on every mock | When using spies extensively and want everything restored |
The safest pattern is to use vi.restoreAllMocks() in your afterEach or configure it globally:
// vitest.config.ts
export default defineConfig({
test: {
restoreMocks: true, // Automatically restores after each test
},
})
When to Mock vs When to Use Real Implementations
This is the most important section. Over-mocking is the number one reason test suites become useless — tests pass, but the app is broken.
Mock When
- Network requests — you don't want tests hitting real APIs (use MSW for integration tests though)
- Non-deterministic values —
Date.now(),Math.random(),crypto.randomUUID() - Heavy side effects — file system writes, database mutations, analytics calls
- Third-party SDKs — services you don't control and can't run locally
- Timers — for testing debounce, throttle, polling without waiting real time
Don't Mock When
- Pure functions — just call them with real inputs.
add(2, 3)does not need mocking - Your own modules — if you're mocking your own utility to test your component, you're testing implementation details
- Data transformations — the whole point is to verify the transformation works with real data
- Simple state logic — reducers, state machines, validation functions
- 1Mock at the boundaries (network, timers, randomness) — not in the middle of your own code
- 2If you mock your own module to test another module, your tests are coupled to implementation
- 3A test that mocks everything tests nothing — it only tests that your mocks are wired correctly
- 4Prefer integration tests with MSW over unit tests with mocked fetch for API-dependent code
- 5Always restore mocks in afterEach or use the restoreMocks config option
Putting It All Together
Here's a realistic example that combines multiple strategies:
import { vi, test, expect, beforeEach, afterEach } from 'vitest'
import { createOrder } from './orders'
import { sendEmail } from './email'
import { generateOrderId } from './utils'
vi.mock('./email')
vi.mock('./utils', async () => {
const actual = await vi.importActual<typeof import('./utils')>('./utils')
return {
...actual,
generateOrderId: vi.fn(() => 'ORDER-TEST-001'),
}
})
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2025-03-15T12:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
test('creates order with correct data', async () => {
vi.mocked(sendEmail).mockResolvedValue({ success: true })
const order = await createOrder({
userId: 'user-1',
items: [{ id: 'item-1', qty: 2 }],
})
expect(order.id).toBe('ORDER-TEST-001')
expect(order.createdAt).toBe('2025-03-15T12:00:00.000Z')
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: expect.any(String),
subject: expect.stringContaining('ORDER-TEST-001'),
})
)
})
Notice the strategy: sendEmail is fully mocked (we don't want to send real emails), generateOrderId is partially mocked (we keep other utils real but need a deterministic ID), and Date is frozen (deterministic timestamps).
| What developers do | What they should do |
|---|---|
| Mocking every import in the test file Over-mocking tests your mocks, not your code. If you refactor internals, tests break even though behavior is unchanged. | Mock only external boundaries — keep your own modules real |
| Using vi.fn() when vi.spyOn() would preserve real behavior vi.fn() replaces behavior entirely. vi.spyOn() lets you track calls while the real code runs — better for verifying side effects. | Use vi.spyOn() when you just need to observe calls without changing behavior |
| Forgetting to restore mocks between tests Leaked mocks cause flaky tests. A spy on console.log in test A silently swallows output in test B, making failures invisible. | Use afterEach with vi.restoreAllMocks() or set restoreMocks: true in config |
| Asserting mock call count without clearing between tests Mock call counts accumulate across tests. Test B might pass because test A already called the mock, masking a real bug. | Use mockClear() or restoreMocks to reset call history before each test |
| Using inline values in vi.mock() factory (hoisting trap) vi.mock() is hoisted above variable declarations. References to outer-scope variables will be undefined at execution time. | Use vi.hoisted() to declare values accessible in both factory and tests |
Mock Types at a Glance
| Strategy | Scope | Best For | Gotcha |
|---|---|---|---|
| vi.fn() | Single function | Callbacks, event handlers, injected dependencies | Returns undefined unless configured |
| vi.spyOn() | Existing object method | Observing real behavior, console methods, class methods | Must call mockRestore() to undo |
| vi.mock() | Entire module | Third-party libraries, API clients, SDKs | Hoisted — cannot reference outer variables without vi.hoisted() |
| __mocks__/ directory | Entire module (reusable) | Analytics, logging, SDKs used across many tests | Must match exact file path structure |
| vi.useFakeTimers() | Global timers + Date | Debounce, throttle, polling, animations | Must call vi.useRealTimers() to clean up |
| vi.stubGlobal() | Global variable | fetch, window properties, environment variables | Persists until manually unstubbed or restored |
Quick Decision Guide
When you're not sure which mock strategy to use, work through this:
- Is it a function you control and pass in? →
vi.fn() - Is it a method on an existing object you want to observe? →
vi.spyOn() - Is it an imported module? →
vi.mock()(or__mocks__/for reuse) - Do you need most of the module real, but one export mocked? →
vi.mock()withvi.importActual() - Is it time-dependent? →
vi.useFakeTimers() - Is it a global like fetch? →
vi.stubGlobal() - Is it none of the above? → Maybe you don't need to mock it