← Concepts & practices
Choice Testing, debugging, and measurement

Fakes, stubs, and mocks

Replace the boundary, keep the behavior.

A weather-alert policy should send one notification when rain probability reaches 70%. If the policy reads a live forecast, the wall clock, and a real notifier in every test, a simple branch becomes slow and hard to explain. Replace the collaborator you need to control, keep the alert policy real, and be honest about what the test no longer exercises.

The judgment to keep

A stub supplies one answer. A fake is a small working replacement. A spy records what happened. A mock-like double adds expectations about an interaction. Choose the smallest replacement that gives the test control, then name the real system boundary it leaves untested.

TypeScriptGo One policy · three ports · four test doubles.

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.

real policyalertForRainthreshold and decision
←
replace oneforecast · clock · notifychoose the test boundary
The double belongs at a collaborator boundary, not around the behavior the test is meant to explain.
Read the starting alertTypeScript · useful policy, hidden clock
weather.ts · direct alert
// 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.

TypeScriptReading
weather.ts
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 };
}
GoAlongside
weather.go
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: &notification}
}
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
weather.go · injected ports
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: &notification}
}

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.

Test doubles

Replace the boundary, keep the behavior under test.

hidden Forecast reader + real wall clock
test inputrainyforecast scenario
→
policy result…waiting for double

forecast requests—

notifications—

hidden: Forecast reader + real wall clock; result sent; The policy works, but time cannot be named.

01/ 04
Call the direct service

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?

You are testing “rain above 70% sends one alert.” What should you replace?

Keep the policy real. Choose the smallest double that gives the test the control it needs.

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.

Startup

Wires the real ports

The live forecast reader, the system clock, and the push notifier, once.

Policy tests

Choose a double

A stub, the fake, or a spy for each case, with alertForRain itself kept real.

Boundary tests

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.

ReactAlready in your code
textbook.tsx · tested alert projection
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.

What to replace, and what remains untested
CollaboratorUseful doubleWhat the test does not prove
ClockFake time returning a fixed instantOS clock behavior, time-zone data, or scheduler timing
Payment gatewayStub one approval or declineProvider authentication, request encoding, and live availability
RepositoryIn-memory fake for a small workflowIndexes, transactions, migrations, and database isolation
Event publisherSpy on published event shapeBroker 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.

Stub

Give an answer

Return a forecast, error, or value that drives one branch of real behavior.

Fake

Act like a small system

Keep simple working state, such as an in-memory store or outbox, behind the same port.

Spy / mock

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.

Test-double decision guide
Your questionStart withBe honest about
Can this branch handle one known answer?StubThe provider and its parsing are outside the test.
Does this flow need several working interactions?FakeThe replacement is a model, not the production system.
Did the meaningful side effect happen?SpyRecord the fields a caller actually cares about.
Is this exact interaction a boundary contract?Mock-like expectationExactness 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

Take the weather policy into your editor. Add a quiet-hours rule that skips alerts before 07:00, then decide which double each new test needs.

Back to Concepts & practices →