New: Debug encrypted microservice traffic with Speedscale's eBPF collector Read the announcement

A developer climbs from a small handwritten response box to a stateful machine, recorded traffic, and a contained realistic service environment

When Handwritten Mocks Stop Scaling

Part 9 of 9 in the Getting Started with Mocks series. Previously: Your Mock Is Lying.


The notifier in this series calls one endpoint. Real systems do not stay that small.

The carrier grows to forty endpoints. Three teams call it, and each keeps its own stubs, written at different times against different versions. Someone adds a field; two of the three suites never hear about it. A fixture file is six months old and nobody can say what it was captured against, or whether it was ever real. The suite is fast and green, and the team has quietly stopped believing it.

That is where hand-writing stops paying for itself. Not at some line count, but at the point where maintaining the doubles costs more than they prove.

🎯 Key Takeaways
  • Every substitute trades fidelity against speed, setup cost, and drift risk; there is no free rung.
  • Start with the smallest substitute that answers your question and move up when a specific risk justifies the cost.
  • Most tests, in most systems, should still be using a function stub.

What each post used, and what it bought

Seven posts, seven positions on the same ladder. One term runs through all of them: a seam is a place where you can change what code does without editing that code, and it is what every substitute below plugs into.

PostSubstituteThe question it answered
2function stubDoes the notifier decide correctly?
3stub returning errors, plus a clock seamDoes it survive a carrier that fails?
4spyDid the notifier ask the sender to notify the customer?
5spy asserted on outcome, not call orderDoes a harmless refactor keep the test green?
6in-memory fakeDoes the state survive between calls?
7in-process fake serverDoes the client speak the protocol?
8captured fixture and a live contract testIs the protocol still what we think?

Stacked up, those positions form a ladder. Fidelity rises to the right, speed falls, and the app under test is the same package notifier throughout:

flowchart LR
    A[Hand-written stub] --> B[In-process fake]
    B --> C[Fake server in process]
    C --> D[Standalone mock server]
    D --> E[Recorded traffic replayed]
    E --> F[Disposable real dependency]

Read down the third column and the pattern is clear. Nothing was replaced because a library recommended it. Each substitute appeared because the previous post’s “What this test does not prove” section named a gap that had started to matter.

The options, honestly

ApproachFidelitySpeedSetupDrift riskMaintained by
Hand-written stubLowestFastestNoneSilentWhoever wrote the test
In-process fakeLowFastNoneSilentYour team, forever
In-process fake serverMediumFastSmallSilent unless validatedYour team
Standalone mock serverMediumMediumA service to runSilent unless validated, but sharedA team, deliberately
Traffic record-and-replayHighMediumA recording stepLower, but recordings still ageWhoever owns the recording
Disposable real dependencyHighestSlowestDocker or an accountLowest when versions matchThe dependency’s authors

Hand-written stub. A function that returns what the test needs. Fastest to write, fastest to run, and it proves your decision logic. It cannot drift loudly, because it has no contract to drift from. It just keeps answering.

In-process fake. A working lightweight implementation, like post 6’s in-memory recorder. It gives you state without infrastructure. Its failure mode is growth: a fake that keeps acquiring behavior becomes a second implementation of your dependency, with its own bugs and nobody assigned to it.

In-process fake server. httptest, MockWebServer, MSW, Python’s http.server. Post 7’s rung. It tests the protocol: paths, headers, status codes, and parsing, while still running in milliseconds. The fixtures are still yours, so drift is still possible; it is just now visible if you captured them.

Standalone mock server. WireMock or MockServer run as a process. The reason to pay that cost is sharing: several services, in several languages, hitting one agreed set of stubbed behavior, which an in-process fake cannot do. You are now running a service, and someone has to own it.

Traffic record-and-replay. Record real requests and responses from a running system and replay them. The fixtures are real by construction, which is the generalized version of post 8’s fix 1. The cost is the recording step and somewhere realistic to record from. proxymock is one implementation: it records inbound and outbound traffic and generates mocks and tests from that recording, locally, without application code changes.

Disposable real dependency. Testcontainers for a database or a broker, or a vendor sandbox account. Highest fidelity, because there is no substitute at all. Slowest by a wide margin, needs Docker or an account, and for most third-party APIs it simply is not available.

Two rungs, one assertion

The companion code for this post makes the comparison concrete. The same assertion runs twice: a delayed package triggers one send request and one record, while only the substitute changes.

Java

@Test
void sameBehaviorWithFunctionStub() throws Exception {
    List<String> messages = new ArrayList<>();
    MemoryRecorder recorder = new MemoryRecorder();

    PackageNotifier notifier = new PackageNotifier(
            trackingNumber -> "delayed",
            (trackingNumber, message) -> messages.add(message),
            recorder,
            millis -> {});
    notifier.notify("TRACK-123");

    assertNotifiedOnce(messages, recorder);
}

Node.js

test('same behavior with a function stub', async () => {
  const messages = []
  const recorder = new MemoryRecorder()

  const notifier = new Notifier(
    async () => 'delayed',
    async (trackingNumber, message) => messages.push(message),
    recorder,
    async () => {},
  )
  await notifier.notify('TRACK-123')

  await assertNotifiedOnce(messages, recorder)
})

Go

func TestSameBehaviorWithFunctionStub(t *testing.T) {
	sender := &spySender{}
	recorder := NewMemoryRecorder()

	n := New(stubStatus("delayed"), sender.Send, recorder, dummySleeper)
	if err := n.Notify("TRACK-123"); err != nil {
		t.Fatal(err)
	}

	assertNotifiedOnce(t, sender, recorder)
}

Python

def test_same_behavior_with_a_function_stub(self) -> None:
    messages: list[str] = []
    recorder = MemoryRecorder()

    notifier = Notifier(
        lambda tracking_number: "delayed",
        lambda tracking_number, message: messages.append(message),
        recorder,
        lambda seconds: None,
    )
    notifier.notify("TRACK-123")

    self.assert_notified_once(messages, recorder)

The second version swaps the stub for a fake server answering with a captured fixture, and asserts exactly the same thing. Both pass. The fake-server version takes longer, needs a fixture, and additionally proves the client builds a correct request and parses a real body. That is the entire trade, visible in one file.

The decision rule

Start at the smallest substitute that answers your question. Move up one rung when a line from a “What this test does not prove” section becomes a material risk or a production incident.

Not when it becomes imaginable. Written down and revisited when something actually matters, those sections become a backlog of known gaps. Risk and experience tell you which one to close. Every rung costs setup, speed, and maintenance and buys back fidelity. Pay for fidelity tied to a concrete risk, not every kind of fidelity you can imagine wanting.

There is also a rung below all of these that is easy to forget: use the real thing. If a dependency is fast, deterministic, and available from the test environment, a substitute adds risk instead of removing it.

What this test proves

That the notifier behaves identically whether its carrier is a function or an HTTP server, which is what makes the two rungs interchangeable for that assertion, and therefore what makes it fair to choose the cheaper one.

What this test does not prove

That either substitute resembles the carrier. Both were written by us, and post 8’s whole argument is that test-double sophistication cannot remove this limit. Higher-fidelity substitutes narrow the gap; only a test against the real dependency closes it for that run.

Try it

List the substitutes in one service you own and put each in a row of the table above. Then find the row you cannot justify, usually a fake that has grown into a second implementation, and move it one rung in either direction.

Where this leaves you

Nine posts, one notifier, and the same idea underneath all of it.

Post 1 said a mock buys control by giving up reality, and every post since has been an argument about the exchange rate. The stub gave up everything about HTTP and bought a delayed package on demand. The fake server bought the protocol back and paid in setup. The contract test bought reality itself and paid in speed and credentials.

Post 2 said the first useful mock is a function. Eight posts of increasingly elaborate machinery later, that is still true, and for most of the tests you write it will stay true. The machinery is for the specific places where a function stopped being enough, and knowing which places those are, rather than which library to install, was the point of the whole series.

The complete runnable code for all nine posts, in Java, Node.js, Go, and Python, is in the companion repository.

Series navigation: Previous: Your Mock Is Lying · All nine posts

Stop writing API mocks by hand

proxymock records real traffic from your running app and replays it as mocks — HTTP, gRPC, Postgres, Kafka, and more. Install in 30 seconds, no account required.