Start with the question your test must answer

A test double stands in for a dependency during a test. The names spy, stub, and mock are useful, but libraries do not always use them consistently. Focus first on the evidence you need. Do you need to see whether a function was called? Do you need to control a value it returns? Or is a particular interaction itself the behavior under test?

Use the least restrictive double that can answer that question. Extra expectations make tests sensitive to harmless implementation changes. For example, a test of a returned profile ID usually does not need to specify every call the function makes along the way. A test of a notification, on the other hand, may need to check the ID sent to the notifier.

Make the dependency easy to replace

Consider a small function that saves a profile and announces its saved ID. Passing its dependencies as arguments gives the test a clear place to substitute them. It also keeps the example independent of a database, network service, or browser API.

The following example uses an ECMAScript module named profile.test.mjs and the built-in node:test and node:assert/strict modules. Run it in a Node.js environment that supports those modules with node --test profile.test.mjs. It needs no third-party test library.

import test from 'node:test'; import assert from 'node:assert/strict'; function saveProfile(profile, store, notify) { const saved = store.save(profile); notify(saved.id); return saved; } test('returns the saved profile and announces its ID', () => { const saved = { id: 'p-1', name: 'Ada' }; const store = { save: () => saved }; const announced = []; const notify = id => announced.push(id); const result = saveProfile({ name: 'Ada' }, store, notify); assert.equal(result, saved); assert.deepEqual(announced, ['p-1']); });

The store supplies a known result, while the array records what the notifier receives. That is enough to establish both outcomes. There is no need to recreate a full storage service or notification system.

Use a spy when observation is enough

A spy records an interaction. It may wrap a real function so the function still runs, or it may simply collect calls, as the announced array does in the example. The important feature is observation: the test can inspect arguments or call counts after exercising the subject.

Suppose the notifier normally updates an in-memory message list, and that update is safe and useful in the test. A wrapper can record the ID and then call the real notifier. This preserves the behavior while exposing the call for inspection. If the output of the real notifier already proves what matters, however, assert on that output instead. A spy is not automatically better than a direct result assertion.

Avoid checking incidental details such as an exact call count when the requirement only says that the correct ID is announced. Conversely, if announcing exactly once is part of the requirement, checking the count is appropriate. Let the requirement set the precision of the assertion.

Use a stub to control an input to the behavior

A stub supplies a planned response from a dependency. In the example, store.save is a stub: it returns the saved object without performing storage. That lets the test concentrate on what saveProfile does with the response. A different test could make save return another ID, or throw an error, to examine those paths deterministically.

Keep stubbed values believable and limited to the case at hand. A large fake database with query rules and internal state can become harder to trust than the function being tested. If the way a real database behaves is important, cover that boundary separately with a test that uses the real integration. A unit test with a stub cannot prove that persistence works.

A stub can also record calls, and many test libraries offer one function that can serve as both stub and spy. Describe it by its job in the test: here, returning a controlled saved profile is the reason the store double exists.

Use a mock when the interaction is the contract

A mock makes an expected interaction part of the test's pass-or-fail condition. For example, if a safety rule requires saveProfile to notify with exactly the ID returned by storage, a notifier mock could reject any other argument and verify that it was called once. This is stronger than merely supplying a return value: the call itself is what the test promises to check.

That strength has a cost. A mock that demands a particular sequence of internal calls can fail after a harmless refactor even when users see the same result. Prefer assertions on returned values and observable effects where possible. Add strict interaction expectations when the interaction is genuinely the requirement, such as calling an audit hook or passing an exact option to a boundary you own.

A practical decision order is to assert an observable result first, add a spy if you must inspect a call, use a stub if you must control a dependency's response, and choose a mock if a specific interaction must be enforced. One double may fill more than one role; the goal is not perfect naming, but a test whose failures clearly explain which behavior changed.

Back to category ↗