vi.spyOn(object, 'method') wraps a real, already existing method of an object: it records calls and by default keeps calling the original. This differs fundamentally from vi.fn(), which creates a stub from scratch. A spy does not replace behavior by itself - it observes the real method, and replacement is turned on separately, only when you ask for it.
Hence the split: vi.fn is for an injectable dependency you pass into a constructor or argument yourself, while vi.spyOn is for a method of a real object that is otherwise hard to replace: console.error, Date.now, a third-party service's method. The rule of choice is to take the narrowest tool: if a dependency can be passed explicitly, spyOn is not needed; it is for cases where the call site cannot be lifted out.
The first mode is to observe without replacing. spyOn wraps the method, the original still runs, and the test checks both the fact of the call and the real effect. In the example a spy is put on service.applyDiscount: the real discount is computed, the test confirms the method was called and that the final amount is right. Afterwards the method is returned to its original state via mockRestore.
The second mode is to replace behavior. On top of the spy you call .mockReturnValue or .mockImplementation, and the method starts returning the given value instead of the real one. A classic example is freezing time: vi.spyOn(Date, 'now').mockReturnValue(...) makes now deterministic for the test. After the check, mockRestore returns the real Date.now, so the replacement does not leak further.
mockRestore works only with spies - and that is the key difference from vi.fn. A spy remembers the original and can return it; a standalone stub has nothing to restore. To avoid calling mockRestore by hand in every test, you enable restoreMocks: true in the config - then all spies are restored automatically after each test, and a leaked replacement becomes impossible by construction.
You can spy on accessors too: vi.spyOn(object, 'property', 'get') or 'set' wraps the getter and setter. This is handy when the observable is a property access rather than a method call. Typical real targets of spies are console.error, to check a warning, Date.now for time, and service methods that cannot be passed as a dependency.
The main danger of a spy is leaving it unclosed. If you replace console.error and do not restore it, the replacement leaks into the next tests: they either break on an unexpected mock or, conversely, quietly swallow real errors. So a spy is always restored - via restoreMocks in the config or mockRestore in afterEach; an unclosed spy is a classic cause of order-dependent flakiness.
The bottom line on choice: vi.fn is for a new injectable dependency, vi.spyOn is for observing or replacing a real existing method, vi.mock is for replacing a whole boundary module. Start with the narrowest: if passing a stub through an argument is enough, do not touch spyOn; if a spy on one method will do, do not mock the whole module. The narrower the intervention, the less the test is coupled to the implementation and the easier it is to maintain later.
test('computes the discount and logs it', () => {
const spy = vi.spyOn(service, 'applyDiscount') // the original still runs
const total = service.total(premiumOrder)
expect(spy).toHaveBeenCalledWith(premiumOrder)
expect(total).toBe(8_100) // the real result, not a stub
spy.mockRestore()
})test('sets the order creation time', () => {
vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000)
const order = createOrder()
expect(order.createdAt).toBe(1_700_000_000_000)
vi.restoreAllMocks() // return the real Date.now
})