01 / The idea
A test double is a choice about the boundary.
The alert rule is small: read a forecast, read the time, and send a notification only when rain risk is high. The weather provider, clock, and notifier are collaborators. They are also places where a test can lose speed, determinism, or focus.
Replacing a collaborator is not automatically “good isolation.” A canned forecast proves what the policy does with that answer, but it does not prove the provider parses a real response. A working fake exercises more flow, but it still is not the provider. A call log shows behavior, while an exact expectation can bind a test to details nobody promised.
Keep the behavior under test real; replace only the boundary the test needs to control.
Read the starting alertTypeScript · useful policy, hidden clock
// This first version is easy to call, but time is hidden inside the policy.
export function directAlert(
city: string,
readForecast: ForecastReader,
notify: Notifier
): AlertResult {
const forecast = readForecast(city);
const checkedAt = new Date().toISOString();
if (forecast.rainProbability < rainThreshold) {
return { status: 'skipped', city, checkedAt, reason: 'rain risk is below the threshold' };
}
const notification: Notification = {
channel: 'weather',
city,
body: `${city}: rain is likely today`,
checkedAt
};
notify(notification);
return { status: 'sent', city, checkedAt, notification };
} The starting function is honest about the policy but not yet about control. Its forecast
reader and notifier are injectable; new Date() is still hidden. The first test
can assert a result, but not a named timestamp.
02 / See the shape
Name what each replacement gives you.
The practical service takes three explicit ports: readForecast, now, and notify. A stub can return a rainy forecast. A fake can
store forecasts and sent messages in memory. A spy can collect calls for a behavioral
assertion. A mock-like test can add a precise expectation when the call itself is a
contract.
The alert policy takes three explicit ports, so a test can replace the forecast, clock, and notifier.
export function alertForRain(city: string, ports: AlertPorts): AlertResult {
const checkedAt = ports.now();
const forecast = ports.readForecast(city);
if (forecast.rainProbability < rainThreshold) {
return { status: 'skipped', city, checkedAt, reason: 'rain risk is below the threshold' };
}
const notification: Notification = {
channel: 'weather',
city,
body: `${city}: rain is likely today`,
checkedAt
};
ports.notify(notification);
return { status: 'sent', city, checkedAt, notification };
} func AlertForRain(city string, ports AlertPorts) AlertResult {
checkedAt := ports.Now()
forecast := ports.ReadForecast(city)
if forecast.RainProbability < rainThreshold {
return AlertResult{Status: "skipped", City: city, CheckedAt: checkedAt, Reason: "rain risk is below the threshold"}
}
notification := Notification{
Channel: "weather",
City: city,
Body: city + ": rain is likely today",
CheckedAt: checkedAt,
}
ports.Notify(notification)
return AlertResult{Status: "sent", City: city, CheckedAt: checkedAt, Notification: ¬ification}
} Reading the TypeScriptPorts, a real policy, and a working fake
alertForRain owns the threshold and message. makeWeatherFake owns only test plumbing: an in-memory forecast map, fixed time,
request log, and notification outbox. The boundary is visible in the type rather than hidden
in a global.
Reading the GoFunction values and explicit state
func AlertForRain(city string, ports AlertPorts) AlertResult {
checkedAt := ports.Now()
forecast := ports.ReadForecast(city)
if forecast.RainProbability < rainThreshold {
return AlertResult{Status: "skipped", City: city, CheckedAt: checkedAt, Reason: "rain risk is below the threshold"}
}
notification := Notification{
Channel: "weather",
City: city,
Body: city + ": rain is likely today",
CheckedAt: checkedAt,
}
ports.Notify(notification)
return AlertResult{Status: "sent", City: city, CheckedAt: checkedAt, Notification: ¬ification}
} Go models the same ports as function values. The fake is a small struct with slices for observations. The language changes; the testing decision does not.
03 / Follow the boundary
Watch the replacement change what you can know.
The frames keep the alert policy constant. First the clock is hidden. Then one stub controls the forecast. A fake makes a small weather world observable. Finally a spy records the notifier boundary without insisting on every internal call.
Replace the boundary, keep the behavior under test.
Forecast reader + real wall clockforecast requests—
notifications—
hidden: Forecast reader + real wall clock; result sent; The policy works, but time cannot be named.
The boundary is hidden.
The direct alert reads a real clock, so a test can see sent versus skipped but cannot name the exact timestamp.
Reduced motion: choose a scene to see its completed state.
Read this scene
The direct alert reads a real clock, so a test can see sent versus skipped but cannot name the exact timestamp.
hidden. Forecast reader + real wall clock. Scenario: rainy. Result: sent. The policy works, but time cannot be named.
Watch restarts when you return. Step through keeps your selected step. Try it starts a fresh test model.
What the choice buys you
Each double answers a different testing question.
- Branch control
- A stub supplies the exact forecast needed to reach rain or clear behavior without a network response.
- Flow confidence
- A fake gives several collaborators a small, deterministic world and lets the test inspect its state.
- Behavior evidence
- A spy records the meaningful notification so the test can assert what a caller would observe.
- Interaction contract
- A mock-like expectation is appropriate when destination, count, or argument shape is itself promised.
Every benefit has a blind spot. The next sections make those blind spots explicit.
04 / Try a decision
Choose the smallest double for the claim.
You need to test the real alert policy’s rainy branch. The weather provider is slow, and the test does not need to verify its HTTP parsing. What should the test replace?
05 / Give it a real job
Make the seam useful beyond the test file.
Dependency injection is not a prize for having many parameters. It is a way to make a meaningful boundary explicit. Keep ports small, pass them at the composition edge, and let production wire the real forecast reader, clock, and notifier while tests choose replacements.
Use a stub for one branch, a fake for a small flow, a spy for an observable side effect, and a mock-like expectation only when the interaction is part of the contract. Then cover the real provider and notifier separately at their boundary.
Wires the real ports
The live forecast reader, the system clock, and the push notifier, once.
Choose a double
A stub, the fake, or a spy for each case, with alertForRain itself kept real.
Check the real thing
The provider’s parser and the notifier each get a test of their own, against a recorded response or a sandbox.
Build UIs?A component that reads the clock or decides the alert pulls the doubles into your component tests.
The textbook component receives an AlertView that the tested policy already
produced, so its tests pass a plain object and need no double at all. The wild component
decides the 70% threshold itself and calls new Date() while rendering. Now every
component test needs a fake clock, and the alert rule has a second copy that the policy tests
never see. Keep the decision behind the port and hand the component its result.
The UI receives a tested alert projection and only renders the result.
type AlertView = {
city: string;
status: 'sent' | 'skipped';
label: string;
checkedAt: string;
};
type WeatherAlertProps = { alert: AlertView };
export function WeatherAlert({ alert }: WeatherAlertProps) {
return (
<article aria-label={`${alert.city} weather alert`}>
<strong>{alert.status === 'sent' ? alert.label : 'No alert'}</strong>
<small>Checked {alert.checkedAt}</small>
</article>
);
}
06 / Recognize it elsewhere
Look for boundaries with a different cost profile.
The names change, but the choice stays recognizable.
| Collaborator | Useful double | What the test does not prove |
|---|---|---|
| Clock | Fake time returning a fixed instant | OS clock behavior, time-zone data, or scheduler timing |
| Payment gateway | Stub one approval or decline | Provider authentication, request encoding, and live availability |
| Repository | In-memory fake for a small workflow | Indexes, transactions, migrations, and database isolation |
| Event publisher | Spy on published event shape | Broker delivery, retries, ordering, and consumer behavior |
07 / Already in your toolbox
Most mocking tools implement several roles at once.
A library may call something a mock, spy, stub, or fake differently. Do not let the label decide the test. Ask what the replacement does and what question the assertion answers.
Give an answer
Return a forecast, error, or value that drives one branch of real behavior.
Act like a small system
Keep simple working state, such as an in-memory store or outbox, behind the same port.
Watch an interaction
Record a call, then decide whether the observed detail or exact expectation is truly contractual.
08 / The parts to watch
A convenient double can make a misleading test.
Replacing the subject under testGreen test, no real behavior
If the test replaces alertForRain and asserts the replacement returns “sent,” it
proves the double. Keep the policy real and replace its collaborator instead.
A fake that is too simpleThe real system has rules the fake forgot
An in-memory repository that ignores uniqueness, transactions, or authorization can create false confidence. Give the fake a narrow purpose and cover the real boundary.
Exact interaction choreographyRefactors fail for no user-visible reason
Call count, order, and full argument equality can be valuable at a message boundary. They are noise when the policy only promises “send one weather notification.”
A spy that observes too littleThe test passes while the message is wrong
Asserting only that a notifier was called misses destination, identity, or payload rules that callers depend on. Observe the meaningful fields.
09 / Make the call
Choose by the question, not by habit.
| Your question | Start with | Be honest about |
|---|---|---|
| Can this branch handle one known answer? | Stub | The provider and its parsing are outside the test. |
| Does this flow need several working interactions? | Fake | The replacement is a model, not the production system. |
| Did the meaningful side effect happen? | Spy | Record the fields a caller actually cares about. |
| Is this exact interaction a boundary contract? | Mock-like expectation | Exactness is valuable but couples the test to the call shape. |
Replace the smallest boundary that buys control.
Pair isolated tests with real-boundary tests for the behavior the double cannot exercise.
Keep this questionAsk it before adding a mock.
What behavior stays real, what boundary am I replacing, and what important behavior does that replacement make impossible to observe?
10 / Take the idea with you
Explain the alert test without saying “mock.”
“The alert rule runs for real. The forecast is a canned answer of 85% rain for Istanbul, the clock is fixed at 09:00, and we recorded the one notification it sent. The test never talks to the weather provider, so a separate test checks that we parse its responses.” That tells a reviewer what is real, what is replaced, and what is left unchecked. When they want the words, they are stub, fake, spy, and mock.
Before moving on, jot down which of the three ports you would replace to test the 70% threshold, what that test can no longer see, and one test in your own code that replaces the very thing it claims to check.
Connections to follow nextRelated lessons
- Dependency injection is how the
three ports reach
alertForRainin the first place. - Property-based testing can run the same policy against many generated forecasts.
- Contract and integration testing reconnects the real boundary the doubles stood in for.
- Pure functions & side effects helps you see which decision can stay free of collaborators.