This is Part 6 of the “Frontend Testing, Done Right” series. Browse the full series · Glossary
“I tested it with a mock” — that phrase actually bundles four different concepts (stub, spy, mock, fake) into one word. This single article sorts out those four terms, shows how one Vitest vi.fn() covers all of them, and settles the question of how far to go with faking. Sort it out once and your eye for reading and writing test code gets much sharper.
If you’ve been following the series — a vi.fn() quietly showed up in last time’s debounce test, with a promise of “properly, next time.” Time to keep it. Landed here from a search? The examples are self-contained. On Jest, read vi.fn() as jest.fn() and vi.spyOn as jest.spyOn.
Practice code: this installment’s exact state lives in the
step-06tag.git checkout step-06to follow along — or diff it against step-05 to see just what this part added.
The questions we’ll answer#
- What actually differs between stub, spy, mock, and fake
- What to fake, and how far
- Why over-mocking is poison

Why you need a stand-in#
Say you’re testing this code — a function that fires an event to an analytics SDK on every search.
function logSearch(query: string) {
analytics.track('search', { query }) // calls an external analytics SDK
}What happens if you call this directly in a test? Fake data piles up on the real analytics server. Run the test a hundred times and you pollute the stats a hundred times; if the network is slow your test is slow; if the SDK has an outage your test breaks — even though your code is fine.
Just as films put a stunt double in for dangerous scenes, tests swap out dangerous or uncontrollable counterparts with stand-ins. These stand-ins are collectively called test doubles. (Double = stand-in, straight from film.)
Stand-ins come in types#
Same stand-in, different jobs. Let’s separate the four.
| Name | What it does | When |
|---|---|---|
| stub | returns only fixed values | “pretend the API returns this” |
| spy | records whether/how many times/with what args it was called | “let’s confirm this got called correctly” |
| mock | spy + verifies “it should be called like this” | “must be called exactly once with these args” |
| fake | a simple working substitute | “an in-memory array instead of a real DB” |
The distinctions are subtle. If it’s your first time, hold onto this: there are really only two things you can ask a stand-in to do — ① answer a question (return a value you decide → stub; do it with a real implementation → fake) and ② witness the scene (record how it was called → spy; verify that record too → mock). The four rows of the table are variations on those two jobs.
Good news: in Vitest, one vi.fn() plays stub, spy, and mock all at once. How you use it becomes its name.
const track = vi.fn() // make it, and it's a spy (records every call)
track.mockReturnValue(true) // set an answer, and it's a stub
expect(track).toHaveBeenCalledWith(...) // verify the record, and you used it as a mock
A fake function from vi.fn() remembers everything that happened to it — how many times it was called, with what arguments, what it returned. You pull that memory back out with matchers like toHaveBeenCalled, toHaveBeenCalledWith, and toHaveBeenCalledOnce.
The four, one at a time, made concrete#
Definitions alone don’t land, so here’s each one as a short scene of “when you’d reach for it.”

stub — “I decide what you hand back.” You want to test the search filter, but the function that supplies data isn’t built yet, or is slow. Plug in a stand-in that only ever gives a fixed answer.
const loadUsers = vi.fn().mockReturnValue([
{ id: '1', name: 'Alice', email: '[email protected]' },
])
// Now loadUsers() always returns just Alice
// Whatever the real data source does, you can quietly verify the filter logic alone
Regardless of the counterpart’s circumstances (server, build progress, randomness), your test’s input is pinned down.
spy — “a function with no result, you verify by whether it was called.” A logging function has no return value. There’s nothing to put in expect(result). Here, observation is the answer.
const track = vi.fn()
someFeature(track) // run the code that uses track
expect(track).toHaveBeenCalled() // "did it get called at all?"
mock — half a step past spy: “it should have been called exactly like this.” When “was called” isn’t enough, pin down the arguments and the count too.
expect(track).toHaveBeenCalledOnce()
expect(track).toHaveBeenCalledWith('search', { query: 'alice' })In Vitest, spy and mock are the same vi.fn() — the difference is just how specifically you verify.
fake — “when it has to actually work, even if simply.” When returning one value isn’t enough and the counterpart has to store-then-retrieve (like a storage), you build a mini implementation.
const fakeStorage = new Map<string, string>()
// a Map instead of setItem/getItem — it 'works' like real localStorage
// but needs no browser and no disk
The 30-second call#
- The stand-in must give a value for the test to proceed → stub
- Whether/how the stand-in was called is itself the thing you’re verifying → spy (pin down args and count too → mock)
- The stand-in must remember state and behave like the real thing → fake
- Stuck? Start with
vi.fn(). As you go, it becomes one of the three.
In practice: testing the analytics logger with a stand-in#
Let’s do it with code we’d genuinely ship. A logger that trims the query and records it as an analytics event — create src/lib/analytics.ts.
export type TrackFn = (event: string, payload?: Record<string, unknown>) => void
/** Trims the query and records it as an analytics event (ignores empty queries) */
export function createSearchLogger(track: TrackFn) {
return (query: string) => {
const q = query.trim()
if (q) track('search', { query: q })
}
}
Notice one design choice here. track (the function that actually sends the event) is received from outside as an argument. In the real app you pass the real SDK function; in tests, you pass a stand-in. This design is called dependency injection — a grand name whose whole point is just “don’t build what you need on the inside; receive it from outside.” That one shift makes all the difference between an easy and a hard test.
Here’s the test. src/lib/analytics.test.ts:
import { describe, it, expect, vi } from 'vitest'
import { createSearchLogger } from './analytics'
describe('createSearchLogger', () => {
it('does not record an empty query', () => {
const track = vi.fn()
const log = createSearchLogger(track)
log(' ')
expect(track).not.toHaveBeenCalled()
})
it('trims the query before recording', () => {
const track = vi.fn()
createSearchLogger(track)(' alice ')
expect(track).toHaveBeenCalledWith('search', { query: 'alice' })
})
})Unpack the second test — feed it ' alice ' (with spaces), and check it was called with { query: 'alice' } (trimmed). We verify not just “was called” but “was called with exactly which arguments.” The real SDK never came near, yet the logger’s behavior is nailed down. Notice too that Part 4’s edge-case question (“what if the input is empty?”) applies right there in the first test.
When you can’t inject — vi.spyOn#
Designing everything to be injected as an argument is ideal, but sometimes you need to watch a method on an object that already exists. That’s what vi.spyOn is for.
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
// ... run code that calls console.warn ...
expect(spy).toHaveBeenCalledWith('deprecated option')
spy.mockRestore() // always restore!
vi.spyOn(obj, 'method') quietly swaps the existing method for a spy. mockImplementation(() => {}) means “just record, don’t actually do the thing (console output).” Ending with mockRestore() is part of the set — the same principle as last time’s useRealTimers. Always restore a world you borrowed.
Break it on purpose#
This installment’s alarm check. Delete trim() from analytics.ts.
const q = query // .trim() deleted!
Save, and two tests go red at once.
× does not record an empty query
→ expected "spy" to not be called at all, but actually been called 1 times
× trims the query before recording
→ expected "spy" to be called with arguments: [ 'search', { query: 'alice' } ]
Received: [ 'search', { query: ' alice ' } ]
The failure messages are quite friendly. The first says “shouldn’t have been called, but was once”; the second lines up “expected vs received” arguments. That report is only possible because vi.fn() was remembering every call. Restore trim() and it’s green again.
Over-mocking is poison#
Convenient as stand-ins are, start faking everything and at some point you’re testing the implementation. A test that verifies “A calls B and B calls C” entirely through mocks shatters the moment you refactor, even when behavior is fine. That’s not a safety net — it’s a shackle that keeps the code from moving.
Set the rule this way: fake only the boundaries — network, time, randomness, external SDKs, the uncontrollable things. Wire your own logic together for real. Even in today’s logger, the only stand-in was the external-SDK slot (track). The network boundary is handled by an expert in the next chapter: MSW.
Wrapping up#
That completes Chapter 1 — the base-fitness training of unit testing. Pure functions (Part 4), async and time (Part 5), and today, stand-ins. You’ve now met just about every opponent that shows up at the function level. From the next chapter, we move the stage to the screen and test components “like a user.”
Add mocks one by one to make a test pass, and at some point you get that “wait, am I testing my own mock now?” moment. That’s when to stop.
Level up: you can tell mock from spy and fake only as much as you truly need to.
Next up: The Testing Library philosophy — querying like a user
Bumped into an unfamiliar term? The glossary has them all, one line each.
