Backend engineering · Testing

API integration tests: prove the contract without calling production

A good integration test should fail when the API contract breaks—not because Wi-Fi, a rate limit, or somebody else’s staging data changed.

When code depends on an external API, “the request returned 200 once” is not much of a test. The useful question is whether the code on each side of the boundary still agrees: which request is sent, which response fields are required, how failures become application-level errors, and what is recorded before a retry. That is a contract. The network is only one way to exercise it.

This matters for small projects too. A test suite that calls a live service may pass today and fail tomorrow because a sandbox was reset, a shared record changed, or a quota ran out. Those failures teach nothing about the code under review. A compact local suite is faster, repeatable, and much more specific about what changed.

Start with the boundary, not the client library

Keep the provider client behind a small adapter instead of passing it through every part of an application. The rest of the code should ask for a domain operation such as create_shipment or lookup_customer. The adapter turns that request into a URL, headers, payload, and timeout. It then turns the provider response into a result the application understands.

That narrow boundary makes the contract visible. Write down the minimum request shape, the response fields the application actually reads, and the status codes that change behavior. Avoid copying a provider’s entire example response into every test; most fields are irrelevant noise. A fixture should look like the smallest truthful response that exercises the path.

Use fixtures as examples, not as decoration

Begin with three fixtures: one successful response, one ordinary validation error, and one response whose shape is surprising but plausible. The last case catches assumptions such as “an optional array is always present” or “an error is always a string.” Keep fixtures in version control beside the adapter, name them after the behavior they represent, and trim tokens, IDs, and timestamps that do not matter.

# fixtures/customer_found.json
{
  "id": "cust_test_123",
  "email": "ada@example.test",
  "status": "active"
}

A fixture is not evidence that the provider will never add fields or return new values. It is a stable example of the behavior the application promises to handle. If the provider documents a breaking change or a real failure reveals a new case, add a fixture that makes the decision explicit.

Assert the outgoing request precisely

Response-only tests miss half the integration. A mock that returns success no matter what it receives can let the code silently send the wrong header, omit an idempotency key, or use a stale path. The test double should capture the request and make the important parts assertions: method, path, headers that affect authorization or replay safety, serialized body, and timeout.

def test_create_customer_sends_expected_request(http):
    http.post(
        "https://api.example.test/v1/customers",
        json={"email": "ada@example.test"},
        headers={"Idempotency-Key": "test-key-1"},
        timeout=5,
    ).respond(201, json=customer_found)

    customer = gateway.create_customer("ada@example.test", "test-key-1")

    assert customer.id == "cust_test_123"

The exact library is not the point. The test should be able to fail for a wrong URL, body, or header. If it cannot, it is closer to a unit test of a happy-path branch than an integration contract test.

Make time and randomness controllable

Retries, signatures, expiration windows, and request IDs often make a test flaky because they depend on the current clock or a random generator. Pass a clock and ID generator into the adapter, or wrap them in tiny functions that tests can replace. Then a retry test can assert the decision—retry once after a retryable failure—without sleeping for a real backoff interval.

The same rule applies to pagination cursors and webhooks. Store a fixed fixture input and a fixed expected output. Tests should make nondeterminism a deliberate input, never an invisible dependency.

Cover failure categories, not every status code

A useful minimum is one case each for: an expected success, a request the provider rejects, a missing or malformed response field, a transient transport failure, and an unexpected server failure. Each should assert the application-facing result. For example, a validation rejection might return a structured error that can be shown to a user, while a timeout might become a retryable exception with no duplicate write.

Do not try to imitate the provider’s full infrastructure locally. The goal is to prove that your adapter reacts correctly to the categories that matter to your application. A small suite with clear names is more valuable than fifty status-code tests that all assert the same generic error.

Keep a separate, narrow live check

Local contract tests replace most live calls; they do not prove credentials, DNS, or a provider’s deployed behavior. When a sandbox is available, keep one opt-in smoke check separate from the normal suite. It should create no durable production data, use dedicated test credentials, and be easy to skip in a pull request. Run it on a schedule or before a risky release rather than on every local save.

Separating these layers makes failures legible. A local contract failure means code or fixtures changed. A smoke-check failure means the environment, credentials, or remote service needs attention. That distinction shortens debugging before it begins.

A short checklist before merging

  • Is the provider hidden behind one small adapter?
  • Does at least one test assert the outgoing method, path, headers, body, and timeout?
  • Do fixtures represent success, a known rejection, a malformed shape, and a transient failure?
  • Are clocks, IDs, and retries deterministic in the test?
  • Is any live check opt-in and separated from the normal suite?

This approach complements the reliability work in my guide to third-party API failure modes: timeouts, retries, idempotency, and rate limits need a test seam before they can be trusted. For systems that turn messy messages into structured records, the same discipline applies to preserving field evidence in an intake schema. The common goal is simple: make the assumptions at a boundary observable and reviewable.