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

A developer's laptop sends packet-shaped messages over a real cable through a wall to a controlled local server

Every earlier post ends with the same admission: the real HTTP client never runs in the tests.

Every test so far replaced the whole carrier lookup with a function. That was the right move, and it is what made a delayed package happen on demand, but it means the code that builds a URL, sets a header, checks a status code, and parses JSON has never once executed in the test suite. If someone deleted the API key header, or misspelled the path, every test would stay green.

This post moves the substitute one layer down. Instead of replacing the client, we replace the carrier.

🎯 Key Takeaways
  • A function stub tests your decision logic; a fake HTTP server tests that your client speaks the protocol.
  • Start a real server on a local port, point the real client at it, and the request is genuinely built, sent, and parsed.
  • The fake server answers with whatever you wrote, so it tests the client’s protocol handling, not the real counterparty.

A real server the test controls

Every language has a way to start an HTTP server, on a real port, that lives as long as the test does:

LanguageToolDependency
Gonet/http/httpteststandard library
JavaOkHttp MockWebServertest scope
Node.jsMSW setupServerdev dependency
Pythonhttp.server.ThreadingHTTPServerstandard library

This is the first post in the series that adds a test dependency, and only in Java and Node.js. Go and Python stay on the standard library.

The client also had to grow up slightly to be worth testing. It now sends an API key and an Accept header on every request: the sort of detail that is invisible until it is wrong.

Assert what was actually sent

The fake server’s job is to capture the request and hand it to the test:

Java

@Test
void sendsTrackingNumberAndApiKey() throws Exception {
    carrier.enqueue(new MockResponse().setBody("{\"status\":\"delayed\"}"));

    new CarrierClient(carrierUrl(), "secret-key").lookup("TRACK-123");

    RecordedRequest request = carrier.takeRequest();
    assertTrue(request.getPath().contains("TRACK-123"), "path was " + request.getPath());
    assertEquals("secret-key", request.getHeader("X-API-Key"));
    assertEquals("application/json", request.getHeader("Accept"));
}

Node.js

test('sends the tracking number and the API key', async () => {
  let request
  server.use(
    http.get(`${CARRIER}/shipments/:trackingNumber`, (info) => {
      request = info.request
      return HttpResponse.json({ status: 'delayed' })
    }),
  )

  await new CarrierClient(CARRIER, 'secret-key').lookup('TRACK-123')

  assert.ok(new URL(request.url).pathname.includes('TRACK-123'))
  assert.equal(request.headers.get('X-API-Key'), 'secret-key')
  assert.equal(request.headers.get('Accept'), 'application/json')
})

Go

func TestClientSendsTrackingNumberAndAPIKey(t *testing.T) {
	var gotPath, gotKey, gotAccept string

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotPath = r.URL.Path
		gotKey = r.Header.Get("X-API-Key")
		gotAccept = r.Header.Get("Accept")
		w.Write([]byte(`{"status":"delayed"}`))
	}))
	defer server.Close()

	client := NewCarrierClient(server.URL, "secret-key")
	if _, err := client.Lookup("TRACK-123"); err != nil {
		t.Fatal(err)
	}

	if !strings.Contains(gotPath, "TRACK-123") {
		t.Errorf("path %q does not contain the tracking number", gotPath)
	}
	if gotKey != "secret-key" {
		t.Errorf("X-API-Key was %q", gotKey)
	}
	if gotAccept != "application/json" {
		t.Errorf("Accept was %q", gotAccept)
	}
}

Python

def test_sends_tracking_number_and_api_key(self) -> None:
    with FakeCarrier() as carrier:
        CarrierClient(carrier.url, "secret-key").lookup("TRACK-123")

    path, headers = carrier.requests[0]
    self.assertIn("TRACK-123", path)
    self.assertEqual("secret-key", headers["X-API-Key"])
    self.assertEqual("application/json", headers["Accept"])

Nothing about the client was stubbed. It built a URL, opened a connection, sent headers, and read a response body. Three assertions that were impossible to write in post 2 are now routine.

Python has a small trap worth knowing: urllib normalizes outgoing header names, so the server sees X-Api-Key rather than X-API-Key. The test compares case-insensitively because HTTP header names are case-insensitive, and pretending otherwise would make the test fail for a reason that does not matter.

Parsing, on purpose

With a server under test control, feeding the client several shipment states is a loop rather than a fixture hunt. The companion suite runs delayed, delivered, in_transit, and lost through the real parser and checks each one comes back intact. That is the first time the JSON decoding in this series has been exercised at all.

Be deliberate about which states you enumerate here. It is tempting to test only delayed, because that is the branch with interesting notifier behavior. But the parser is shared by every state. A decoding bug that appears only on in_transit can reach production quietly because the decision logic never examines that value closely enough to complain.

The failures that matter

Two responses deserve their own tests, because they are the ones that go wrong in production and the ones a function stub can never produce:

Java

@Test
void treatsNon200AsCarrierUnavailable() {
    carrier.enqueue(new MockResponse().setResponseCode(503).setBody("upstream is down"));

    assertThrows(
            CarrierUnavailableException.class,
            () -> new CarrierClient(carrierUrl(), "secret-key").lookup("TRACK-123"));
}

@Test
void failsCleanlyOnMalformedJson() {
    carrier.enqueue(new MockResponse().setBody("{\"status\": "));

    assertThrows(
            JsonProcessingException.class,
            () -> new CarrierClient(carrierUrl(), "secret-key").lookup("TRACK-123"));
}

Node.js

test('treats a non-200 as the carrier being unavailable', async () => {
  server.use(
    http.get(`${CARRIER}/shipments/:trackingNumber`, () =>
      HttpResponse.text('upstream is down', { status: 503 }),
    ),
  )

  await assert.rejects(
    () => new CarrierClient(CARRIER, 'secret-key').lookup('TRACK-123'),
    CarrierUnavailableError,
  )
})

Go

func TestClientTreatsNon200AsCarrierUnavailable(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "upstream is down", http.StatusServiceUnavailable)
	}))
	defer server.Close()

	_, err := NewCarrierClient(server.URL, "secret-key").Lookup("TRACK-123")
	if !errors.Is(err, ErrCarrierUnavailable) {
		t.Fatalf("expected ErrCarrierUnavailable, got %v", err)
	}
}

Python

def test_treats_non_200_as_carrier_unavailable(self) -> None:
    with FakeCarrier(status_code=503, body='{"error":"upstream is down"}') as carrier:
        with self.assertRaises(CarrierUnavailableError):
            CarrierClient(carrier.url, "secret-key").lookup("TRACK-123")

def test_fails_cleanly_on_malformed_json(self) -> None:
    with FakeCarrier(body='{"status": ') as carrier:
        with self.assertRaises(json.JSONDecodeError):
            CarrierClient(carrier.url, "secret-key").lookup("TRACK-123")

The 503 becomes the carrier-unavailable error from post 3. That proves the retry budget covers a failing server, not just a dropped connection. Those two failures were always meant to be the same case, but until this post nobody had checked.

Malformed JSON is deliberately not that error. Retrying a truncated body might help; the important thing is that the client raises something rather than returning an empty status and letting the notifier conclude the package is fine. Silence is the worst failure mode a notifier has.

Two boundaries, two questions

The function stub and the fake server are not competitors. Run the same scenario through both and they answer different questions:

  • Stub the function: given delayed, does the notifier warn the customer once and record it? Instant, no sockets, no parsing.
  • Fake the server: given the bytes a carrier sends, does the client turn them into delayed? Slower, and it covers everything between the socket and the seam.

The seam in that second line is the one post 2 cut: the place where the notifier takes its shipment-status lookup as a parameter, so a test can change what the code does without editing it. Stubbing the function replaces the seam. Faking the server leaves it alone and replaces what sits behind it.

The two boundaries sit at different depths in the same app:

flowchart LR
    N[Package notifier] --> S{{ShipmentStatus seam}}
    S -->|stub test| B["Stub: returns delayed"]
    S -->|wire test and production| H[HTTP carrier client]
    H -->|in production| C[(Real carrier API)]
    H -->|in a wire test| F[(Fake carrier server)]

Everything between the seam and the socket, the URL, the headers, the JSON, is skipped by the stub test and exercised by the wire test. That strip of code is exactly what the earlier posts kept admitting they did not cover.

Keep both. The stub suite is where you enumerate behavior, because it costs nothing to add another case. The wire suite is small on purpose: it exists to prove the client works, not to re-test decisions the stub suite already covers.

What this test proves

The client speaks the protocol we think the carrier speaks. It puts the tracking number in the path, sends the API key, turns a 503 into the error the retry logic expects, parses every shipment state we know about, and refuses a broken body instead of guessing.

What this test does not prove

That the carrier actually speaks it.

Every byte the fake server returned was written by us. If the carrier’s real response has the field somewhere else, or renames it next quarter, this suite goes on passing with total confidence. The fake is now much more realistic and exactly as uninformed.

That is the subject of the next post.

Try it

Make the fake server return 401 when the X-API-Key header is missing, then construct a client without a key and watch what happens. Decide deliberately whether a missing key should be a carrier-unavailable error: it is a configuration bug, so retrying it three times is the wrong answer. The full runnable suite for all four languages is in the companion repository.

Next: the carrier renames a field and every test stays green. Your Mock Is Lying.

Series navigation: Previous: They Aren’t All Mocks · All nine posts · Next: Your Mock Is Lying

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.