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

Two parcel scanners compare an old connector that passes with a changed connector that fails while a developer notices the mismatch

The carrier ships a new version of its API. In the shipment payload, status becomes shipmentStatus. Nothing else changes: same endpoint, same auth, same 200 OK, same valid JSON, every other field in place.

In production, the notifier stops warning anyone. It reads a field that is no longer there, gets nothing back, decides no package is delayed, and goes quiet. No alarm fires, because nothing failed. The carrier is up. Latency is fine. The error rate is zero.

Run the test suite and it is green. All of it. The stubs from post 2 never parsed JSON, and the fake server from post 7 returns whatever body we typed into it.

🎯 Key Takeaways
  • Every double you write encodes your belief about a dependency, and beliefs do not get release notes.
  • Capture fixtures from real responses so they were at least true once, and record when.
  • A scheduled contract test against the real dependency is the only thing that notices when reality moves.

The problem has a name

Six posts of test doubles, and every one of them was written by us. The stub answers delayed because we said so. The spy records what we send it. The fake recorder implements the promise we decided the database makes. The fake HTTP server returns bytes we typed.

That is not a flaw in any of those doubles. It is the whole point of them, and it is what let us make a delayed package happen on demand. But it has a consequence worth stating plainly: a test suite made entirely of doubles is a test of our beliefs about the world, run against a copy of those same beliefs. It agrees with itself, forever, for free.

Dependencies do not send release notes to your test suite. Something has to go and look.

flowchart LR
    C[(Real carrier API)] -->|changes shape| DRIFT{Drift}
    B["Our doubles: stub,<br/>fake server, fixture"] -->|frozen at write time| DRIFT
    DRIFT -->|invisible to| G["Every mocked test, still green"]
    DRIFT -->|caught by| L["Something that touches the real API"]

The whole of this post is the bottom arrow: what that something is, how often it runs, and what it compares.

Fix 1: fixtures that were true at least once

The first change is the cheapest. Stop typing response bodies and start capturing them.

Post 7’s fake server answered with {"status":"delayed"}, twenty characters that never came from a carrier. Replace that with a file captured from a real response:

{
  "trackingNumber": "TRACK-123",
  "status": "delayed",
  "carrier": "example-carrier",
  "lastScan": {
    "location": "Memphis, TN",
    "timestamp": "2026-08-28T14:22:11Z"
  },
  "estimatedDelivery": "2026-09-02",
  "delayReason": "weather"
}

Most of those fields the notifier never reads. Keep them. They cost nothing, they are what a real response looks like, and they are what makes the file worth comparing against a live response later, which is fix 4.

A captured fixture does not stop drift. It does mean the fixture was true once, and that is a real improvement over a body that was never true at all. Record three things alongside it: when it was captured, what it was captured against (production and a sandbox account are different contracts), and how, so a colleague can recapture it. Generating fixtures from recorded real traffic, which is what tools like proxymock do, is the general form of this fix.

Fix 2: make missing fields loud

Capturing a fixture exposes a second bug, one that was there all along. Ask the old client for a field that is missing and it hands back an empty string. The notifier reads “not delayed” and stays silent.

So the client stops guessing:

Java

if (!shipment.has("status")) {
    throw new CarrierContractException("no status field");
}
return shipment.get("status").asText();

Node.js

if (!Object.hasOwn(shipment, 'status')) {
  throw new CarrierContractError('no status field')
}
return shipment.status

Go

	if shipment.Status == nil {
		return "", fmt.Errorf("%w: no status field", ErrCarrierContract)
	}
	return *shipment.Status, nil

Python

if "status" not in shipment:
    raise CarrierContractError("no status field")
return shipment["status"]

Go needs a pointer and Java needs has() rather than path() for the same reason: both languages will otherwise hand you a zero value that looks exactly like a real answer. Telling “missing” apart from “empty” is the entire fix.

The new error is deliberately not the carrier-unavailable error from post 3, and the retry budget skips it. An outage is worth asking again about. A carrier that answers correctly in a shape we do not understand will answer identically three times in a row, and retrying only adds load to a service that is working.

Now the renamed fixture fails the suite loudly:

Java

@Test
void rejectsTheRenamedField() throws Exception {
    carrier.enqueue(new MockResponse().setBody(fixture("carrier-shipment-delayed-v2.json")));

    IOException error = assertThrows(
            CarrierContractException.class,
            () -> new CarrierClient(carrierUrl(), "secret-key").lookup("TRACK-123"));

    assertFalse(error instanceof CarrierUnavailableException);
}

Node.js

test('rejects the renamed field', async () => {
  serveFixture('carrier-shipment-delayed-v2.json')

  await assert.rejects(
    () => new CarrierClient(CARRIER, 'secret-key').lookup('TRACK-123'),
    (error) => {
      assert.ok(error instanceof CarrierContractError)
      assert.ok(!(error instanceof CarrierUnavailableError))
      return true
    },
  )
})

Go

func TestRenamedFieldIsRejected(t *testing.T) {
	server := serveFixture(t, fixture(t, "carrier-shipment-delayed-v2.json"))

	_, err := NewCarrierClient(server.URL, "secret-key").Lookup("TRACK-123")
	if !errors.Is(err, ErrCarrierContract) {
		t.Fatalf("expected ErrCarrierContract, got %v", err)
	}
	if errors.Is(err, ErrCarrierUnavailable) {
		t.Fatal("a contract change is not an outage")
	}
}

Python

def test_rejects_the_renamed_field(self) -> None:
    with FakeCarrier(body=fixture("carrier-shipment-delayed-v2.json")) as carrier:
        with self.assertRaises(CarrierContractError) as caught:
            CarrierClient(carrier.url, "secret-key").lookup("TRACK-123")

    self.assertNotIsInstance(caught.exception, CarrierUnavailableError)

This is progress with a hard limit. The suite now catches the rename once someone hands it the renamed fixture. Nobody has yet gone and asked the carrier.

Fix 3: go and ask

A contract test calls the real thing and asserts that its answers still have the shape the code depends on. It needs credentials and the network, so it is opt-in and stays out of the commit loop:

CARRIER_CONTRACT_TEST=1 \
CARRIER_URL=https://api.your-carrier.example \
CARRIER_API_KEY=... \
CARRIER_TRACKING_NUMBER=... \
  go test ./08-your-mock-is-lying/... -run Contract

Every language in the companion repository skips these unless that variable is set: t.Skip in Go, @EnabledIfEnvironmentVariable in JUnit, the skip option in node:test, and @unittest.skipUnless in Python. Put them in a nightly job. When they fail, the carrier changed, and you find out on a Tuesday morning rather than from a customer.

Fix 4: compare the fixture to reality

The live contract test checks only the fields your code reads today. The next step is stricter: fetch a live response, load the captured fixture, and compare the shape.

Compare field names, not values. Tracking numbers and timestamps are supposed to differ; the set of keys is not. A field in the fixture that is missing from the live response fails the test. A field in the live response that is not in the fixture is reported rather than failed: it means the carrier added something and your fixture is due for a recapture.

This catches renames the notifier would not otherwise notice, including in the fields it never reads, which is precisely where next quarter’s bug is hiding.

Fidelity and speed both belong

The obvious reaction is to distrust the fast tests. That is the wrong lesson.

The mocked suite runs in milliseconds and can create any situation on demand, which is why it carries the coverage. The contract tests are slow, need credentials, and cannot make a package be late, but they are the only ones connected to reality. Each is bad at what the other is good at.

A practical arrangement is mocked tests on every commit, contract tests on a schedule, and a comparison between the fixtures and a live response so the two cannot silently disagree.

What this test proves

The client rejects a response that does not carry the field it needs, instead of treating a missing field as a negative answer. With the contract tests enabled, it also proves the carrier’s current response still has the shape the fixture captured.

What this test does not prove

Offline, it proves nothing about today’s carrier, only about the fixture. The contract tests close that gap while they are running, which is once a night, so between runs you are trusting a snapshot.

The comparison also only checks top-level field names. A field that keeps its name and changes its meaning, such as status going from delayed to DELAYED or switching to a numeric code, passes every check here.

Try it

Rename a second field in the drifted fixture, estimatedDelivery to eta, and run the suite. The client never reads that field, so the contract error does not fire and every offline test stays green. Only the fixture-versus-live comparison notices. That gap is the difference between testing what you parse and testing what you were sent. The full suite is in the companion repository.

Next: the carrier grows to forty endpoints and hand-written doubles stop paying for themselves. When Handwritten Mocks Stop Scaling.

Series navigation: Previous: Mock the Wire · All nine posts · Next: When Handwritten Mocks Stop Scaling

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.