Skip to content
QSWEQB
mediumConceptCodingDesignMidSenior

How do you test a service that calls a third-party API, without calling it?

Substitute at the network layer or behind your own client interface, not at the HTTP library's function. Stubbing the library couples the test to how you make the call rather than to what you send, so the test keeps passing through refactors that change the request and breaks on refactors that change nothing.

4 min readUpdated 2026-07-26

What the interviewer is scoring

  • Does the candidate choose the substitution point deliberately and justify it
  • Whether they recognise that stubbing the HTTP function stops verifying the request itself
  • That the risk of a fake drifting from the real API is acknowledged and addressed
  • Whether error and timeout paths are tested rather than only the success case
  • Does the candidate keep at least one test that exercises the real integration somewhere

Answer

The choice is where to cut

There are three places to substitute, and picking without thinking is what produces tests that verify nothing.

At the HTTP client function. Replace fetch or the axios module with a stub. Easy, and the most common answer, and the weakest: the test now asserts that your code called a function with certain arguments, which is a statement about your implementation rather than about the request that would go out. Change to a different HTTP library and every test breaks though the behaviour is identical. Build the URL wrongly and the test happily passes, because the stub accepts whatever it is given.

At the network layer. An interceptor such as nock, or a local process like MSW, catches the outbound request after your code has fully constructed it. The test can now assert on the URL, the method, the headers and the body — the things that actually determine whether the integration works. The HTTP library becomes an implementation detail you are free to change.

Behind your own client interface. Wrap the third party in a PaymentGateway of your own, and let tests of the surrounding logic use a simple fake. This is the right level for testing your business rules, because those rules do not care about HTTP at all.

The two useful ones are the last two, and they answer different questions. Use the network layer to test the client itself — that it forms the request correctly and interprets each response. Use your own interface to test everything above it, where a real HTTP round trip would be noise. Roughly one set of tests for the adapter, many for the logic.

What stubbing the library costs you

Worth making concrete, because the failure is silent.

// Stubbed at the library. Passes whether or not the URL is right.
fetch.mockResolvedValue({ ok: true, json: async () => ({ id: "pay_1" }) });
await gateway.charge(cents, token);
expect(fetch).toHaveBeenCalled();

Compare with intercepting the request:

// The assertion is on the request itself, so a wrong path, a missing
// idempotency header, or amount sent in pounds instead of pence all fail.
nock("https://api.payments.example")
  .post("/v1/charges", { amount: 1250, currency: "gbp", source: "tok_x" })
  .matchHeader("idempotency-key", /.+/)
  .reply(200, { id: "pay_1", status: "succeeded" });

await gateway.charge(1250, "tok_x");

The second test fails for the reasons a real integration fails. The first fails when you rename a variable.

The fake will drift, and you have to plan for it

Every one of these approaches shares one weakness: your fake is your belief about the API, and beliefs go stale. The provider adds a required field, starts returning 202 where it returned 200, changes an error shape — and your suite is entirely green while production is broken.

Nothing removes this risk, so the job is to reduce and detect it.

Generate fixtures from reality where you can. Record real responses once and use them as the fixture, so at least the shape was true at some point, and re-record on a schedule. Hand-written fixtures tend to be idealised — the fields you remembered, none of the nulls the real API sends.

Keep a small number of tests that hit the real thing. Against a sandbox if the provider offers one, run outside the merge-gating pipeline so a third-party outage does not block deploys, and alerting somewhere a human will see. A handful of these catch the drift that thousands of mocked tests cannot.

And monitor the integration in production, since for a third party you cannot influence, that is where you will find out first regardless.

Test the paths that are not success

Mocking makes failure cases easy, which is the underrated benefit, and most suites still only cover the happy path.

The ones that matter: a 500 from the provider, a 429 with a retry-after, a timeout, a connection reset mid-response, a 200 with a body that does not match the schema, and a duplicate submission if the API is meant to be idempotent. Each has a distinct correct behaviour in your code and each is nearly impossible to trigger against the real service on demand.

The timeout case is worth calling out because people wait for it. Injecting a delayed response and using fake timers means testing a 30-second timeout takes milliseconds; without that the test either takes 30 seconds or, more often, gets skipped.

Substitute at the network boundary, not at the function you happened to call. A test that asserts you called fetch is a test of your own code's shape, and it will pass on the day the request goes out malformed.

Likely follow-ups

  • Your fake returns 200 and the real API started returning 202. What catches that?
  • How do you test the timeout path without waiting for the timeout?
  • When would you record real responses instead of hand-writing them?
  • Where do contract tests fit if the provider is a third party you cannot influence?

Related questions

testingtest-doubleshttp-mockingcontract-testingnodejs