A mock function is a stub that records its calls and lets you set a return value or behavior. In Vitest it is created by vi.fn(). Such a function has history: mock.calls stores the arguments of every call, and matchers like toHaveBeenCalledWith and toHaveBeenCalledTimes check how it was called. The point of a mock is not to merely record the fact of a call but to check the result and the interaction at the system's boundary.
The classic case is a dependency at the boundary. A checkout service depends on a payment gateway and a mailer; in the test both are replaced by vi.fn() with a set return value. After calling checkout you check not the service's internals but the observable: that charge was called with the right amount and currency, and sendReceipt exactly once. The test describes the service's contract with its boundaries, not its implementation.
Mock behavior is set with methods. mockReturnValue returns a value synchronously, mockResolvedValue and mockRejectedValue a promise, mockImplementation replaces the body entirely. Each has a Once variant (mockResolvedValueOnce) that applies to one next call - this is the key to modeling a sequence of different responses rather than one constant one.
A sequence of responses tests retry logic. The request function is mocked so the first call fails with a timeout and the second returns success, and you confirm the withRetry wrapper saw it through and went exactly twice. Here the mock describes not one value but a scenario over time - exactly what a retry exists for.
You should check the result and arguments, not merely the fact of a call. toHaveBeenCalled only says the function fired; toHaveBeenCalledWith(expect.objectContaining(...)) checks that it received the important fields. The first matcher lets a call with wrong arguments through, the second catches it. Precision in checking interaction matters as much as precision in checking a value. The same principle applies to toHaveBeenCalledTimes: what matters is not that the function was called but how many times and with what arguments.
A spy is justified when the call itself is the contract: emitting a domain event, recording a metric, committing a transaction. Here the observable effect is exactly the boundary call, and checking it is right. But checking the order of private calls inside a module is not needed: that is a binding to the implementation, because of which a harmless refactor breaks the test even though the outward behavior did not change.
Mocks require isolation. Between tests their history is cleared and replaced methods restored - the clearMocks/restoreMocks policy is set once, covered in detail in the lifecycle chapter. Without it the call history leaks from test to test, and toHaveBeenCalledTimes starts counting foreign calls, giving false, order-dependent failures.
The typical failure is a mock that does not reflect the boundary's real contract: it returns an extra field, a wrong type, or always success where the real service sometimes fails. The test is green but production is not, because you checked invented behavior. A mock must be an honest stub of the boundary: its shape and failure modes must match what the real dependency does.
test('sends the receipt only after a successful payment', async () => {
const gateway = { charge: vi.fn().mockResolvedValue({ paymentId: 'pay-1' }) }
const mailer = { sendReceipt: vi.fn().mockResolvedValue(undefined) }
const service = new CheckoutService(gateway, mailer)
await service.checkout(order)
expect(gateway.charge).toHaveBeenCalledWith(1_490, 'RUB')
expect(mailer.sendReceipt).toHaveBeenCalledTimes(1)
})const request = vi.fn()
.mockRejectedValueOnce(new TimeoutError())
.mockResolvedValueOnce({ ok: true })
await expect(withRetry(request)).resolves.toEqual({ ok: true })
expect(request).toHaveBeenCalledTimes(2)