Skip to content

Mocking Strategies with vi

intermediate18 min read

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.

Mental Model

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)
Quiz
What does vi.fn() return when called without any mock configuration?

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)
Quiz
What is the key difference between vi.fn() and vi.spyOn()?

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),
}))
Warning

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.).

Quiz
Why do you need vi.importActual() inside a vi.mock() factory?

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
Info

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:

MethodWhat It ClearsWhen to Use
mockClear()Call history, instances, results — but keeps implementationBetween 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 mockIn global beforeEach/afterEach for blanket cleanup
vi.resetAllMocks()Runs mockReset() on every mockWhen all tests need completely fresh mocks
vi.restoreAllMocks()Runs mockRestore() on every mockWhen 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
  },
})
Quiz
You use vi.spyOn(console, 'log') in a test but forget to restore it. What happens in subsequent tests?

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 valuesDate.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
Key Rules
  1. 1Mock at the boundaries (network, timers, randomness) — not in the middle of your own code
  2. 2If you mock your own module to test another module, your tests are coupled to implementation
  3. 3A test that mocks everything tests nothing — it only tests that your mocks are wired correctly
  4. 4Prefer integration tests with MSW over unit tests with mocked fetch for API-dependent code
  5. 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 doWhat 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

StrategyScopeBest ForGotcha
vi.fn()Single functionCallbacks, event handlers, injected dependenciesReturns undefined unless configured
vi.spyOn()Existing object methodObserving real behavior, console methods, class methodsMust call mockRestore() to undo
vi.mock()Entire moduleThird-party libraries, API clients, SDKsHoisted — cannot reference outer variables without vi.hoisted()
__mocks__/ directoryEntire module (reusable)Analytics, logging, SDKs used across many testsMust match exact file path structure
vi.useFakeTimers()Global timers + DateDebounce, throttle, polling, animationsMust call vi.useRealTimers() to clean up
vi.stubGlobal()Global variablefetch, window properties, environment variablesPersists until manually unstubbed or restored
Quiz
You need to test that a component calls analytics.track() when a button is clicked, but you do not want to actually send analytics events. Which strategy is best?

Quick Decision Guide

When you're not sure which mock strategy to use, work through this:

  1. Is it a function you control and pass in?vi.fn()
  2. Is it a method on an existing object you want to observe?vi.spyOn()
  3. Is it an imported module?vi.mock() (or __mocks__/ for reuse)
  4. Do you need most of the module real, but one export mocked?vi.mock() with vi.importActual()
  5. Is it time-dependent?vi.useFakeTimers()
  6. Is it a global like fetch?vi.stubGlobal()
  7. Is it none of the above? → Maybe you don't need to mock it