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-Afterheader - 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, 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. 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, 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:
- 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.
- 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.
- 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 ↗- TypeScript
typescript/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?
Related topics
- Integration TestingTesting your code against real dependencies — databases, HTTP clients, message brokers — with Testcontainers, and what belongs at this level rather than above or below it.
- WireMockA programmable HTTP server for tests — stubbing responses, injecting failures, verifying requests, and recording real traffic to replay.
- Contract TestingProving two services still agree without deploying both — consumer-driven contracts with Pact, provider verification in CI, and where contract testing beats end-to-end.
- Test DoublesDummy, stub, spy, mock and fake — Meszaros's five kinds of test double, what each is for, and why using the words precisely makes code reviews shorter.
- Flaky TestsWhy tests fail intermittently, the six root causes and how to fix each one, how to detect flakiness deliberately, and what to do with a test you cannot fix today.
- API TestingTesting HTTP and message interfaces directly — the level most teams under-invest in relative to its value, and how it replaces most slow end-to-end tests.
- Component TestingTesting one deployable in isolation through its real interface, with its own dependencies containerised and everything beyond its boundary stubbed.
- End-to-End TestingWhat belongs in an end-to-end suite and what does not, how many journeys are enough, and the practices that keep a browser suite from becoming the thing everyone ignores.