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