Skip to content
End To End Tester

Integration Testing with Stubs

Testing against a dependency you control — WireMock, MSW and in-process servers — so error paths, timeouts and rate limits become testable instead of theoretical.

3 min read · updated 19 September 2026

The problem with real dependencies is that you cannot make them misbehave on request. A payment provider will not return 503 because your test needs one, and nothing you can do will make a third-party API time out reliably at 2,000ms.

So you put something you control on the other end of the socket. Your HTTP client, serialization, timeouts, retries and error mapping all run for real; only the far side is fabricated.

This is meaningfully different from mocking the client interface, which skips the entire network layer and therefore skips every bug that lives in it.

#What this level is for

Almost entirely the unhappy paths:

  • 500, 502, 503 and whether the retry policy behaves
  • 429 with a Retry-After header
  • a connection that accepts and then never responds (timeout handling)
  • malformed JSON, or valid JSON with an unexpected shape
  • a slow response that crosses your timeout by 50ms
  • TLS failure, DNS failure, connection reset

Every one of those happens in production. Almost none of them are tested, because testing them requires a dependency that can be told to fail.

#WireMock

The standard tool in the JVM world and available standalone as a container for everyone else. See WireMock for depth.

java
// Java, JUnit 5. Real HTTP over a real port; the client is unmodified.
class RatesClientTest {

    static WireMockServer wireMock;

    @BeforeAll static void start() {
        wireMock = new WireMockServer(options().dynamicPort());
        wireMock.start();
    }

    @Test
    void gives_up_after_the_configured_retries() {
        wireMock.stubFor(get(urlEqualTo("/v1/rates/gbp"))
            .willReturn(aResponse().withStatus(503)));

        var client = new RatesClient(wireMock.baseUrl(), Duration.ofMillis(200), 2);

        assertThatThrownBy(client::gbpRate)
            .isInstanceOf(RatesUnavailableException.class);   // mapped, not leaked

        wireMock.verify(3, getRequestedFor(urlEqualTo("/v1/rates/gbp")));  // 1 + 2 retries
    }

    @Test
    void times_out_rather_than_hanging() {
        wireMock.stubFor(get(urlEqualTo("/v1/rates/gbp"))
            .willReturn(aResponse().withFixedDelay(5_000).withStatus(200)));

        var client = new RatesClient(wireMock.baseUrl(), Duration.ofMillis(200), 0);

        assertThatThrownBy(client::gbpRate).isInstanceOf(RatesTimeoutException.class);
    }
}

dynamicPort() is not incidental. A fixed port is the reason an integration suite cannot be run in parallel, and it is one of the more common causes of "passes alone, fails in CI".

#MSW, for JavaScript and the browser

Mock Service Worker intercepts at the network layer — in Node via http/fetch interception, in the browser via a service worker — so the same handler definitions serve unit tests, component tests and a local dev server.

typescript
// TypeScript. One set of handlers, reused by Jest and by the browser app.
import { http, HttpResponse, delay } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer();
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

it('surfaces a rate limit to the caller rather than retrying forever', async () => {
  server.use(
    http.get('https://api.example.com/v1/rates/gbp', () =>
      HttpResponse.json({ message: 'slow down' }, {
        status: 429,
        headers: { 'retry-after': '120' }
      })
    )
  );

  await expect(fetchGbpRate()).rejects.toMatchObject({
    name: 'RateLimited',
    retryAfterSeconds: 120
  });
});

onUnhandledRequest: 'error' is the setting that makes this trustworthy: a request nobody stubbed fails loudly rather than escaping to the real internet, which is how a test suite ends up accidentally depending on a production API.

#The in-process server

For a single test, a real server in five lines beats any library.

python
# Python, pytest. A real socket, an ephemeral port, no dependencies.
import json, threading
from http.server import BaseHTTPRequestHandler, HTTPServer

def serve(handler_fn):
    class Handler(BaseHTTPRequestHandler):
        def do_GET(self): handler_fn(self)
        def log_message(self, *args): pass          # keep pytest output clean

    server = HTTPServer(("127.0.0.1", 0), Handler)   # port 0 => ephemeral
    threading.Thread(target=server.serve_forever, daemon=True).start()
    return server

def test_maps_a_502_to_a_domain_error():
    def bad_gateway(request):
        request.send_response(502); request.end_headers()

    server = serve(bad_gateway)
    client = RatesClient(f"http://127.0.0.1:{server.server_port}", retries=0)

    with pytest.raises(RatesUnavailable):
        client.gbp_rate()

    server.shutdown()

#The drift problem, stated honestly

A stub encodes what you believe the dependency does. Beliefs go stale: the provider adds a field, changes an error code, starts returning null where it used to omit the key. Your tests keep passing, because they are testing your belief.

Three defences, in increasing order of cost and effectiveness:

  1. Record rather than write. Capture real responses once and replay them. WireMock and VCR-style libraries both do this. At least the fiction started as fact.
  2. Validate stubs against the schema. If the provider publishes OpenAPI, assert that every stubbed response validates against it. This catches shape drift without any coordination.
  3. Contract testing. The provider verifies your expectations against its actual implementation, in its own pipeline. This is the only approach that catches drift before it reaches you, and it requires the provider's cooperation — which is why it works inside an organisation and rarely outside one.

#When not to stub

If the dependency is yours and containerised, run it. A real instance of your own service in a container has no drift problem at all and tests the same code paths. Save stubbing for the third-party boundary and for failure injection, which is the thing a real instance cannot give you.

Common questions

When should I stub an HTTP dependency instead of calling the real one?
Whenever the real one is third-party, rate-limited, costs money per call, or cannot produce the response you need to test. You cannot ask Stripe to return a 503 on demand, and a test that only covers the happy path leaves the retry logic unexercised.
Is a stubbed integration test still an integration test?
Yes — your HTTP client, serialization, timeout and retry code all run for real over a real socket. What is faked is the far side of the wire. That is a meaningfully different thing from mocking the client interface, which skips all of it.
How do I stop my stubs from drifting away from the real API?
Record them from the real service and re-record periodically, or add contract tests that verify the stub definitions against the provider's published schema. A stub that has not been checked against reality for a year is a fiction your tests believe.

Runnable samples for this page

last test results ↗
  • TypeScripttypescript/src/testing-levels/integration-testing-with-stubs

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?