C.W.K.
Stream
Lesson 01 of 04 · published

vi.fn and vi.spyOn — Functions That Remember

~17 min · vitest-mocking, vi.fn, vi.spyOn

Level 0Test Curious
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"A mock is a function that remembers it was called."

vi.fn() — The Building Block

vi.fn() returns a brand-new mock function. By default it returns undefined on every call, but it records every call into a .mock object you can inspect — arguments, return values, even the this binding. That recording is what makes it a mock.

You configure it with three families of methods:

  • Return values: mockReturnValue(x) for every call, mockReturnValueOnce(x) for the next call only (queue-style).
  • Async returns: mockResolvedValue(x), mockRejectedValue(err) — sugar for Promise-shaped returns.
  • Full implementation: mockImplementation(fn) when the mock needs to compute something based on inputs.

Inspecting What Happened

After your code-under-test runs, you assert on the recording. The two dominant patterns:

  • expect(mock).toHaveBeenCalled() / toHaveBeenCalledTimes(n) — was it called, how many times?
  • expect(mock).toHaveBeenCalledWith(args) / toHaveBeenLastCalledWith(args) — what arguments did it see?

For more surgical inspection, reach into mock.calls directly — it's an array of argument arrays, one entry per call.

vi.spyOn — When the Function Already Exists

vi.fn() creates a function out of nothing. vi.spyOn(obj, 'method') wraps a method that's already on an object. The wrapped version still calls the original by default, but every call is recorded, and you can override the implementation when you need to.

The killer feature is restoration. spy.mockRestore() puts the original method back. Pair with afterEach(() => vi.restoreAllMocks()) and you never leak a spy into another test.

Reach for vi.spyOn when you want to observe without replacing. Reach for vi.fn() when you need a fresh fake to pass in as a dependency. The two are not interchangeable in spirit, even when both would work — the choice signals intent to the reader.

The Three Reset Methods (Pick One Convention)

Vitest gives you three cleanup methods that look similar and aren't:

  • mockClear() — clears the call history (mock.calls, mock.results). Keeps the implementation and the return-value queue.
  • mockReset() — does mockClear() plus resets the implementation back to the default (return undefined).
  • mockRestore() — only for spies; restores the original method. Implies mockReset().

The cleanest convention: put afterEach(() => vi.restoreAllMocks()) at the top of every test file (or in vitest.setup.ts globally), and never worry about manual cleanup. The config option restoreMocks: true does the same thing implicitly.

Mocks leak by default. A spy you forget to restore stays attached for the rest of the test process. The next test in the same file runs against the spied method, not the real one, and you spend an afternoon debugging why a perfectly correct function returns undefined. Set the cleanup once at the project level.

Code

vi.fn() — record, configure, inspect·typescript
import { describe, it, expect, vi } from 'vitest';

describe('vi.fn() basics', () => {
  it('records every call', () => {
    const handler = vi.fn();

    handler('hello');
    handler('world', { important: true });

    expect(handler).toHaveBeenCalledTimes(2);
    expect(handler).toHaveBeenCalledWith('hello');
    expect(handler).toHaveBeenLastCalledWith('world', { important: true });

    // Surgical inspection
    expect(handler.mock.calls[0]).toEqual(['hello']);
    expect(handler.mock.calls[1][1].important).toBe(true);
  });

  it('can return configured values', () => {
    const getUser = vi.fn()
      .mockReturnValueOnce({ id: 1 })
      .mockReturnValueOnce({ id: 2 })
      .mockReturnValue({ id: 'fallback' });

    expect(getUser()).toEqual({ id: 1 });
    expect(getUser()).toEqual({ id: 2 });
    expect(getUser()).toEqual({ id: 'fallback' });
    expect(getUser()).toEqual({ id: 'fallback' });
  });

  it('can model async behavior', async () => {
    const fetchUser = vi.fn().mockResolvedValue({ name: 'Pippa' });
    await expect(fetchUser(1)).resolves.toEqual({ name: 'Pippa' });
  });
});
vi.spyOn — wrap, observe, restore·typescript
import { describe, it, expect, vi, afterEach } from 'vitest';

afterEach(() => {
  vi.restoreAllMocks(); // single line, never leak
});

describe('vi.spyOn — observe without losing the original', () => {
  it('lets you check that console.warn was called', () => {
    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

    callCodeThatWarns();

    expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('deprecated'));
    // After the test, restoreAllMocks puts console.warn back to normal.
  });

  it('can replace the method while still recording calls', () => {
    const fetch = vi.spyOn(global, 'fetch').mockResolvedValue(
      new Response(JSON.stringify({ ok: true }), { status: 200 })
    );

    return fetchUserById(42).then((user) => {
      expect(user.ok).toBe(true);
      expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/users/42'));
    });
  });
});
Project-wide cleanup config — set once, never leak·typescript
// In vitest.config.ts — set restoreMocks once, forget cleanup forever
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'happy-dom',
    setupFiles: ['./vitest.setup.ts'],
    restoreMocks: true,   // auto-restore spies between tests
    clearMocks: true,     // auto-clear call history between tests
    // unstubGlobals: true, // also unstub vi.stubGlobal calls
  },
});

External links

Exercise

Write a notify(user, channel) function that calls one of sendEmail(user.email, msg), sendSMS(user.phone, msg), or sendPush(user.deviceId, msg) based on the channel argument. Test it three times using vi.fn() for each sender — assert that the right sender was called with the right arguments, and the others were NOT called (toHaveBeenCalledTimes(0)). Bonus: use vi.spyOn on an object holding all three senders instead of separate vi.fns.
Hint
If you find yourself writing if (channel === 'email') expect(sendEmail).toHaveBeenCalled() and so on in one big test, split into three tests. Each test names one channel and asserts the contract.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.