A spy is a wrapper around a real method that already exists. jest.spyOn(obj, 'method') finds obj.method, wraps it in a mock function, and returns that mock, yet behind the wrapper the live implementation is still there. A spy does two things at once: it records every call - the arguments and the number of invocations - and by default it still calls the original and returns its result. So without a single extra line it replaces nothing; it merely steps in between the code and the method to watch it.
This is what sets it apart from jest.fn. jest.fn() is a standalone stub: there is no real implementation behind it, and it does exactly what you told it to. A spy, by contrast, is always tied to something that already exists - a method of a concrete object - and it observes or overrides that very method. Need a new stand-in call that does not exist in the code yet, say a dependency for a service - reach for jest.fn. Need to trace or temporarily change a method of an existing object without rewriting it - that is a job for spyOn.
The most underrated mode is to spy while replacing nothing. Take a CheckoutService with an applyDiscount method that the service calls inside checkout. We put a spy on service.applyDiscount and run checkout as usual. The real discount is computed for real - the spy did not switch it off - and we additionally confirm that the method received the right items and that the total worked out.
The moment you add .mockImplementation or .mockReturnValue, the original is switched off - the spy stops calling the live method and answers by your script. You need this when the real implementation gets in the way: it goes over the network, reads the clock, touches the file system. The classic example is Date.now. The service stamps a receipt with the current time, and without a replacement the test would depend on the second it ran. jest.spyOn(Date, 'now').mockReturnValue(...) freezes time, and the issuedAt field becomes predictable.
Here the spy's main advantage surfaces - it can be put back. mockRestore() restores the real implementation: the wrapper comes off, Date.now shows the real time again. This works only with spies, because only a spy has something to restore - it remembers the original it wrapped. A jest.fn has nothing to restore: there is emptiness behind it. That is why mockRestore and spyOn are a pair.
Doing this by hand in every test is easy to forget, so the config offers the restoreMocks: true option - it calls the equivalent of restoreAllMocks before every test. You can spy on more than methods. A third argument sets the access type: jest.spyOn(config, 'apiUrl', 'get') intercepts the getter, and 'set' the setter. That is how you replace computed properties - an environment flag or a base URL - without touching the rest of the object.
A spy has a single danger, but a sneaky one - the spy you never close. Set jest.spyOn(Date, 'now').mockReturnValue(...) and forget restore, and the wrapper outlives the test and leaks into the next ones. A neighboring test that knows nothing about frozen time suddenly gets a stuck Date.now and fails or, worse, passes for the wrong reason. Such tests flicker: green in isolation, red in the full run, and the culprit sits in an entirely different file.
From this a simple selection rule follows. Need a new dependency that does not exist in the code - jest.fn. Need to temporarily replace or merely trace a method of an existing object and then honestly give it back - jest.spyOn with a mandatory restore, ideally via restoreMocks. Need to switch off a whole module at a system boundary - jest.mock, but that is a last resort. The spy is the most delicate of the three.
test('computes the discount and observes the call', () => {
const service = new CheckoutService(gateway, mailer)
const applyDiscount = jest.spyOn(service, 'applyDiscount')
const total = service.checkout(order)
// the real applyDiscount ran - we only observed it
expect(applyDiscount).toHaveBeenCalledWith(order.items)
expect(total).toBe(1_490)
applyDiscount.mockRestore()
})const now = jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000)
const receipt = service.checkout(order)
expect(receipt.issuedAt).toBe(1_700_000_000_000)
now.mockRestore() // the real Date.now is back in place