mediumEngineering Practices#73

Mock functions with Jest

Prompt

Create a Jest mock function for a callback. Test that it was called, with the correct arguments, and exactly N times.

Solution

it('calls callback for each item', () => {
  const mockCallback = jest.fn(x => x * 2)
  processItems([1, 2, 3], mockCallback)
  expect(mockCallback).toHaveBeenCalledTimes(3)
  expect(mockCallback).toHaveBeenCalledWith(1)
  expect(mockCallback).toHaveBeenCalledWith(2)
  expect(mockCallback).toHaveBeenCalledWith(3)
})
Mentor's take

jest.fn() exists to test the contract at a boundary: when your unit's job is "call this collaborator correctly," the mock records every call so you can assert on the interaction instead of the collaborator's side effects. toHaveBeenCalledTimes catches both under-calling (skipped items) and over-calling (double-fires — the bug class behind duplicate analytics events and double payments), and toHaveBeenCalledWith pins the argument contract.

The vocabulary distinction interviewers probe, inherited from Sinon: a spy records calls without changing behavior, a stub replaces behavior to control the test's inputs, and a mock carries pre-programmed expectations that fail the test themselves. jest.fn() collapses all three into one API — it spies always, stubs when you give it an implementation, and becomes assertion material via the matchers.

The senior discipline is knowing when not to mock. Every mock couples the test to an implementation detail: mock too much and refactoring breaks tests while behavior stays correct, which trains the team to ignore red. Mock at real boundaries — network, time, randomness, native modules — and let everything inside the unit run for real.

Red flag: mocking every dependency reflexively. A suite where refactors break tests but bugs don't is testing implementation, not behavior.

Say it: "I mock at boundaries — network, time, native modules — and assert the call contract with toHaveBeenCalledTimes and toHaveBeenCalledWith; everything inside the unit runs real."