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

A developer chooses among five distinct testing tools on a workshop bench, including a plug, answer box, camera, working machine, and checkpoint

This series has called every stand-in a mock since post 1. That was deliberate. Learning the taxonomy before using the tools is a good way to memorize five words and apply none of them.

By now you have used three. The carrier lookup in post 2 was a canned answer. The sender in post 4 recorded what it was given so the test could look afterwards. Those are different tools, and calling both of them mocks is what makes people reach for a mocking framework when a two-line function would do.

This post adds the last collaborator, sorts the whole cast, and gives you names that predict how a test will behave.

🎯 Key Takeaways
  • A stub answers a question, a spy records a side effect, a fake really works, a mock fails the test itself, and a dummy just fills a parameter slot.
  • Pick the double by what the collaborator does, not by what your mocking library is called.
  • A fake is the right choice when the second call has to see what the first call wrote.

The last collaborator

The notifier asks a carrier for a status, decides whether to warn the customer, and sends the warning. One thing is missing: it never records that it did. Without that, a retry sends a second text message to a customer who has already been told, which is the kind of bug that reaches your support inbox before it reaches your test suite.

So the notifier gets a Recorder. It stores that a tracking number was notified, and it can answer whether that already happened.

Here is the version the tests use. It is not a canned answer and it is not a call log. It is a working recorder that happens to keep its state in a map:

Java

public final class MemoryRecorder implements Recorder {
    private final Map<String, String> messages = new ConcurrentHashMap<>();

    @Override
    public void record(String trackingNumber, String message) {
        messages.putIfAbsent(trackingNumber, message);
    }

    @Override
    public boolean notified(String trackingNumber) {
        return messages.containsKey(trackingNumber);
    }

    public Optional<String> message(String trackingNumber) {
        return Optional.ofNullable(messages.get(trackingNumber));
    }

    public int count() {
        return messages.size();
    }
}

Node.js

export class MemoryRecorder {
  #messages = new Map()

  async record(trackingNumber, message) {
    if (this.#messages.has(trackingNumber)) {
      return
    }
    this.#messages.set(trackingNumber, message)
  }

  async notified(trackingNumber) {
    return this.#messages.has(trackingNumber)
  }

  message(trackingNumber) {
    return this.#messages.get(trackingNumber) ?? null
  }

  get count() {
    return this.#messages.size
  }
}

Go

type MemoryRecorder struct {
	mu       sync.Mutex
	messages map[string]string
}

func NewMemoryRecorder() *MemoryRecorder {
	return &MemoryRecorder{messages: make(map[string]string)}
}

func (r *MemoryRecorder) Record(trackingNumber, message string) error {
	r.mu.Lock()
	defer r.mu.Unlock()

	if _, seen := r.messages[trackingNumber]; seen {
		return nil
	}
	r.messages[trackingNumber] = message
	return nil
}

func (r *MemoryRecorder) Notified(trackingNumber string) bool {
	r.mu.Lock()
	defer r.mu.Unlock()

	_, seen := r.messages[trackingNumber]
	return seen
}

Python

class MemoryRecorder:
    def __init__(self) -> None:
        self._messages: dict[str, str] = {}

    def record(self, tracking_number: str, message: str) -> None:
        self._messages.setdefault(tracking_number, message)

    def notified(self, tracking_number: str) -> bool:
        return tracking_number in self._messages

    def message(self, tracking_number: str) -> str | None:
        return self._messages.get(tracking_number)

    @property
    def count(self) -> int:
        return len(self._messages)

Note the second write. Recording the same tracking number twice keeps the first message rather than overwriting it, because the production recorder makes that same promise and a double-send is the bug we are trying to prevent. A double that quietly disagrees with the real implementation is worse than no double at all.

That behavior is what makes this a fake: a working lightweight implementation. What it answers depends on what you did to it earlier.

What a fake buys you

Here is the test that could not be written with a canned answer:

Java

@Test
void notifiesTheCustomerOnlyOnce() throws Exception {
    SpySender sender = new SpySender();
    MemoryRecorder recorder = new MemoryRecorder();
    PackageNotifier notifier =
            new PackageNotifier(stubStatus("delayed"), sender, recorder, DUMMY_SLEEPER);

    for (int i = 0; i < 3; i++) {
        notifier.notify("TRACK-123");
    }

    assertEquals(1, sender.messages.size());
    assertEquals(1, recorder.count());
}

Node.js

test('notifies the customer only once', async () => {
  const send = spySender()
  const recorder = new MemoryRecorder()
  const notifier = new Notifier(stubStatus('delayed'), send, recorder, dummySleep)

  for (let i = 0; i < 3; i++) {
    await notifier.notify('TRACK-123')
  }

  assert.equal(send.messages.length, 1)
  assert.equal(recorder.count, 1)
})

Go

func TestCustomerIsNotifiedOnlyOnce(t *testing.T) {
	sender := &spySender{}
	recorder := NewMemoryRecorder()
	n := New(stubStatus("delayed"), sender.Send, recorder, dummySleeper)

	for i := 0; i < 3; i++ {
		if err := n.Notify("TRACK-123"); err != nil {
			t.Fatal(err)
		}
	}

	if len(sender.messages) != 1 {
		t.Fatalf("sent %d messages, expected 1", len(sender.messages))
	}
	if recorder.Count() != 1 {
		t.Fatalf("recorded %d packages, expected 1", recorder.Count())
	}
}

Python

def test_notifies_the_customer_only_once(self) -> None:
    send = SpySender()
    recorder = MemoryRecorder()
    notifier = Notifier(stub_status("delayed"), send, recorder, dummy_sleep)

    for _ in range(3):
        notifier.notify("TRACK-123")

    self.assertEqual(1, len(send.messages))
    self.assertEqual(1, recorder.count)

A canned answer cannot express this. If the recorder always says “not yet notified” the loop sends three messages. If it always says “already notified” the first message never goes out. The behavior under test lives precisely in the change between the first call and the second, and only something that really stores state can produce it.

That is the rule for fakes: reach for one when the second call has to see what the first call wrote.

The whole cast, sorted

Every double in the notifier’s test suite, named for what it does:

Stub, a canned answer. The carrier lookup from post 2. It ignores the tracking number and returns "delayed" because the test said so. Use a stub when the collaborator answers a question and you need a particular answer.

Spy, a record of calls. The sender from post 4. It performs no real work and keeps every message so the test can look afterwards. Use a spy when the collaborator performs a side effect and you need to confirm it happened.

Fake, a working implementation. The recorder above. Use a fake when the collaborator holds state.

Dummy, a value that fills a parameter. The no-op sleep function. Post 3 put the clock behind a seam, a place where you can change what code does without editing that code, and that seam has to be given something even though most tests genuinely do not care that time passed. Use a dummy when the parameter is required and irrelevant.

Mock, a double that fails the test itself. This is the one worth seeing once, because it is the only one that changes who does the asserting. At this point the sender receives both the tracking number and the message. These examples accept both values but assert only on the message, which is the behavior this test protects:

Java

private static final class MockSender implements Sender {
    private final String expectedMessage;
    private int calls;

    MockSender(String expectedMessage) {
        this.expectedMessage = expectedMessage;
    }

    @Override
    public void send(String trackingNumber, String message) {
        calls++;
        if (calls > 1) {
            fail("sender called " + calls + " times, expected exactly 1");
        }
        assertEquals(expectedMessage, message);
    }

    void verify() {
        assertEquals(1, calls, "expected exactly one send");
    }
}

Node.js

function mockSender(expectedMessage) {
  let calls = 0
  const send = async (trackingNumber, message) => {
    calls++
    assert.equal(calls, 1, `sender called ${calls} times, expected exactly 1`)
    assert.equal(message, expectedMessage)
  }
  send.verify = () => assert.equal(calls, 1, `sender called ${calls} times, expected exactly 1`)
  return send
}

Go

type mockSender struct {
	t               *testing.T
	expectedMessage string
	calls           int
}

func (m *mockSender) Send(trackingNumber, message string) error {
	m.t.Helper()
	m.calls++
	if m.calls > 1 {
		m.t.Fatalf("sender called %d times, expected exactly 1", m.calls)
	}
	if message != m.expectedMessage {
		m.t.Fatalf("sender got %q, expected %q", message, m.expectedMessage)
	}
	return nil
}

func (m *mockSender) verify() {
	m.t.Helper()
	if m.calls != 1 {
		m.t.Fatalf("sender called %d times, expected exactly 1", m.calls)
	}
}

Python

class MockSender:
    def __init__(self, test: unittest.TestCase, expected_message: str) -> None:
        self._test = test
        self._expected_message = expected_message
        self._calls = 0

    def __call__(self, tracking_number: str, message: str) -> None:
        self._calls += 1
        self._test.assertEqual(1, self._calls, "sender called more than once")
        self._test.assertEqual(self._expected_message, message)

    def verify(self) -> None:
        self._test.assertEqual(1, self._calls, "expected exactly one send")

The spy stores calls and lets the test decide what to make of them. The mock carries the expectations inside itself and fails the moment they are broken. Martin Fowler’s Mocks Aren’t Stubs draws exactly this line: “only mocks insist upon behavior verification.”

Mockito, node:test mock functions, gomock, mockery, and unittest.mock can generate or record that shape for you. They earn their keep on an interface with fifteen methods you would otherwise stub by hand. On a one-method collaborator they add a dependency and hide the mechanism. This series does not adopt them, and the suite above uses spies and fakes everywhere except this one demonstration.

Laid over the app, each name belongs to a seam rather than floating free. The notifier is unchanged; only what you hang on each seam differs, and the name follows the job.

flowchart LR
    N[Package notifier] --> S{{ShipmentStatus seam}}
    N --> D{{Sender seam}}
    N --> R{{Recorder seam}}
    N --> W{{Sleep seam}}
    S -->|stub| B["Canned answer"]
    D -->|spy| P["Records the calls"]
    R -->|fake| F["Working in-memory store"]
    W -->|dummy| X["Value nobody reads"]

Reach for a mock rarely, and knowingly. A test that fails because a call arrived in a different order is the subject of post 5, and it is the failure mode strict mocks make easy to build. If you want the stub-versus-mock distinction on its own, the Mock vs Stub post covers it in more depth.

What this test proves

The notifier tells a customer about a delayed package exactly once, no matter how many times it runs, and it records what it sent. Because the recorder is a real implementation rather than a scripted one, that claim survives a change to how the notifier checks for prior notification.

What this test does not prove

The fake is not the database. It does not have a unique constraint, a transaction, a connection that can drop mid-write, or a second process racing for the same row. If your production recorder relies on the database to enforce “only once,” the fake agrees with it by convention, not by mechanism, and the two can drift apart silently.

It also still proves nothing about HTTP. Across posts 2 through 6, the real carrier client has never run in a test. That changes next.

Try it

Open your own test suite and find something named mockFoo. Decide what it actually does: answers a question, records calls, or really works. Rename it, and notice whether the name you needed was the one already there.

Next: the fake HTTP server that finally runs the real client. Mock the Wire.

Series navigation: Previous: Test Behavior, Not Choreography · All nine posts · Next: Mock the Wire

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.