A mock function is a stand-in call under observation. jest.fn() creates a function that does nothing useful on its own, but it has three faces. The first is the result: you can tell it what to return. The second is history: it remembers every call it received. The third is behavior: it can return different things on different calls, by a script. These three roles make it an isolation tool - we replace a real dependency with a controllable stub and fully own its response.
The call history lives in mock.calls - an array of the arguments of each invocation - and the matchers toHaveBeenCalledWith and toHaveBeenCalledTimes work on top of it. Take a CheckoutService that receives a payment gateway and a mailer. Both are mocks: gateway.charge and mailer.sendReceipt. After calling checkout we query the history: the gateway received the amount 1490 and the currency RUB, and the receipt went out exactly once.
Behavior is set before the call. mockReturnValue returns a synchronous value, mockResolvedValue a promise with a result, mockRejectedValue a rejected promise. The point is determinism: a real payment gateway goes over the network, answers differently and slowly, whereas mockResolvedValue({ paymentId: 'pay-1' }) gives the same predictable answer instantly. The test stops depending on the outside world and checks only the service's logic.
The naive trap is to take the fact of a call for the result. 'The method was invoked, so it works.' But the fact of a call says nothing about the amount, the order, or the effect. gateway.charge might have been called with the wrong amount; sendReceipt might have gone out before the payment was confirmed. A 'was called' check is green in both cases, yet the behavior is broken - the test asserts too little.
So by default check the result and the effect, not the fact of the invocation. What the service returned, what state the cart ended up in, whether the receipt went out with the right data - this is the observable behavior the code exists for. A state check survives refactoring: as long as the result is the same, the test stays green even if the internals were rewritten. A call check, by contrast, breaks on any rearrangement that does not change behavior.
There are cases where the call itself is the contract. Publishing an event to a bus, incrementing a metric, committing a transaction, sending a receipt to the customer - here the observable result is precisely that the call happened with the right arguments; it has no other 'output'. In such places toHaveBeenCalledWith is the right and precise assertion: we check exactly the side effect the service is obliged to produce.
Behavior can be scripted step by step. mockRejectedValueOnce(new TimeoutError()) makes the first call fail, and mockResolvedValueOnce({ ok: true }) makes the second succeed; this models a flaky network for retry logic. The test for withRetry checks two things at once: the result is successful in the end, and the request was made exactly twice. Here the number of calls is part of the retry contract, not a binding to implementation, so checking it is appropriate.
A telling failure is a test that pins down the order of private internal calls: first validate was invoked, then normalize, then save. Such a test is bound to the implementation, not the behavior: rearrange the steps or merge them without changing the result, and the green test turns red without a single real bug. Keep mocks at the system boundaries, check the result and observable effects - then mock functions protect behavior rather than preserving yesterday's code.
test('sends the receipt only after a successful payment', async () => {
const gateway = {
charge: jest.fn().mockResolvedValue({ paymentId: 'pay-1' }),
}
const mailer = {
sendReceipt: jest.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 = jest.fn()
.mockRejectedValueOnce(new TimeoutError())
.mockResolvedValueOnce({ ok: true })
await expect(withRetry(request)).resolves.toEqual({ ok: true })
expect(request).toHaveBeenCalledTimes(2)