Spy
A wrapper that lets the real function run while recording every call it got, so you can assert it was called with the right arguments.
See it
What it is
A spy wraps a real function and keeps a logbook: how many times it was called, with which arguments, in what order, and what it returned. The real behavior still happens underneath, which is what separates a spy from a stub (canned answer, no real call) and a mock (fake plus built-in expectations). In practice the tools blur this: 'vi.spyOn' and 'jest.spyOn' give you a spy, and adding '.mockImplementation()' quietly turns it into a stub.
Reach for a spy when the thing you care about is the fact of the call, not its result: 'did we fire the analytics event', 'did the retry loop actually try three times', 'did onSave get the edited row and not the stale one'. Sinon calls this a test spy; the assertion usually reads like 'expect(track).toHaveBeenCalledWith('signup', { plan: 'pro' })'.
Two gotchas. First, spies patch a live object, so a spy you never restore leaks into every test after it: use 'restoreMocks' or 'afterEach(() => vi.restoreAllMocks())'. Second, spying on a function a module calls internally usually does nothing, because the module holds its own binding and never looks your patched copy up again. Inject the dependency instead of chasing it through the module system.
Ask AI for it
Write a unit test that uses a spy to verify a side effect. Use vi.spyOn (Vitest) on the collaborator so the real implementation still runs, call the function under test, then assert on the recorded calls: toHaveBeenCalledTimes, toHaveBeenCalledWith, and the order of arguments. Restore the spy in afterEach with vi.restoreAllMocks() so it does not leak into other tests. Do not stub the return value unless the test needs it.