Back to all writing

How to Test an API Integration Without Calling the Real API

Test API integrations with HTTPX mock transports and stateful fakes. Cover request contracts, malformed responses, safe retries and provider compatibility.

A blue glass connector is coupled to a local inspection chamber, with interchangeable blue and amber test cartridges on a gunmetal bench

A customer submits a request through your application. The application creates a support ticket in another system and saves its reference so the team can follow up.

That is the version everyone tries in the demo.

The more difficult version starts when the connection drops, a token expires or the ticket service sends back something your application cannot understand. The customer is still waiting, and the team needs to know whether a ticket exists before anyone tries again.

These are ordinary situations for software that connects business systems. They should be possible to test before a customer encounters them.

But calling the real service for every test creates another problem. Tests can consume quotas, create unwanted records and fail because a provider is having a bad day. Some failures are difficult to trigger on demand at all.

My approach is to run most checks against a controlled substitute for the provider, exercise the application's real integration code, and keep a smaller set of checks against the actual service. Each part answers a different question.

Be clear about what the integration promises

For the ticket workflow, I want to answer three questions:

Part of the integrationWhat I need to check
The outgoing requestDoes it use the right endpoint, authentication, headers and data?
The responseCan the application recognise a usable ticket reference and reject an unusable one?
RecoveryWhat happens when the service refuses the request, takes too long or returns an unclear result?

That last question matters to the person using the application. They need to know whether the request was saved, whether work is still pending and what they can safely do next.

A test that checks only the final ticket ID leaves much of this behaviour untested.

Keep the connector in the test

A mock is a controlled stand-in for something the test depends on. Where you put it determines what you actually test.

Suppose the application calls create_ticket(), and the test replaces that entire function with one that always returns T-123.

That can be useful for testing the rest of the application. It tells you what happens after a ticket reference comes back. It gives you no evidence about whether the connector sends the right request or understands the provider's response.

For the connector's own tests, I move the substitute one step further out: to the part of the HTTP client that would send the request over the network.

The real connector still builds the request, checks its status and reads its response. A test handler receives that request and supplies the response.

That is the boundary supported by HTTPX's mock transport.

A small example you can run

The ticket service below is fictional. Assume its contract requires a bearer token, a title, an Idempotency-Key header and a response containing a top-level ticket ID. A real provider will have its own requirements, including what an idempotency key actually guarantees.

Save this as test_tickets.py:

import json

import httpx
import pytest


def create_ticket(client, title, request_key):
    response = client.post(
        "/tickets",
        json={"title": title},
        headers={"Idempotency-Key": request_key},
    )
    response.raise_for_status()

    payload = response.json()
    ticket_id = payload.get("id") if isinstance(payload, dict) else None
    if not isinstance(ticket_id, str) or not ticket_id.strip():
        raise ValueError("Provider returned an invalid ticket receipt")

    return ticket_id


def client_for(handler):
    return httpx.Client(
        base_url="https://tickets.invalid",
        headers={"Authorization": "Bearer test-token"},
        timeout=5.0,
        transport=httpx.MockTransport(handler),
    )


def test_request_and_response():
    def provider(request):
        assert request.method == "POST"
        assert request.url == httpx.URL("https://tickets.invalid/tickets")
        assert request.headers["Authorization"] == "Bearer test-token"
        assert request.headers["Idempotency-Key"] == "task-42"
        assert json.loads(request.content) == {"title": "Fix export"}
        return httpx.Response(201, json={"id": "T-123"})

    with client_for(provider) as client:
        assert create_ticket(client, "Fix export", "task-42") == "T-123"


@pytest.mark.parametrize("payload", [
    {},
    {"id": None},
    {"id": 7},
    {"id": ""},
    {"id": " "},
    {"ticket": {"id": "T-123"}},
    [],
])
def test_rejects_unusable_receipts(payload):
    def provider(request):
        return httpx.Response(201, json=payload)

    with client_for(provider) as client:
        with pytest.raises(ValueError, match="invalid ticket receipt"):
            create_ticket(client, "Fix export", "task-42")

In a Python virtual environment, install the two test dependencies and run it:

python -m pip install httpx pytest
python -m pytest -q test_tickets.py

The first test inspects the request produced by the connector. The other cases check that a successful HTTP status does not persuade the connector to accept a missing, empty or incorrectly structured ticket reference.

There is no external network call. The handler supplies the response directly.

These checks establish how the connector behaves against the example contract. They cannot establish that a real provider accepts that contract. They also do not exercise DNS, TLS or the timing behaviour of a real network connection.

Test the failures people will need help recovering from

I would build out the test cases around the actual provider and workflow, rather than trying to collect every possible HTTP status.

SituationBehaviour worth checking
An access token expiresRefresh through the supported flow when appropriate; stop and explain when access cannot be recovered.
The provider returns a rate limitRespect supported retry guidance, limit retries and show whether the work is waiting or has stopped.
The response is malformedReject an unusable result at the connector boundary. Keep enough context to investigate without logging credentials or private payloads.
Pagination stops making progressDetect a repeated cursor, enforce page and item limits, and avoid reporting an incomplete collection as complete.
A write times outPreserve the uncertainty about the external result and follow the provider's reconciliation or safe-retry contract.

For authentication, I would also test several requests encountering an expired token together. With providers that rotate refresh tokens, competing refresh attempts can cause problems that a single-request test will miss.

For pagination, an empty page is not necessarily the end. Some APIs can return an empty page with a continuation token. The stopping rule should follow the provider's contract, not an assumption that every page contains records.

For retries, check the settings in the provider's client library as well as your own code. If both retry independently, the total number of requests may be much higher than the application team expects. Amazon's explanation of timeouts and backoff describes how retries can add pressure to an already struggling dependency.

Simulate the write happening before the timeout

There are two very different tests hidden inside the phrase "the request timed out."

In one, the provider never creates a ticket. In the other, it creates the ticket and the response never reaches your application.

If the test always models the first situation, automatically retrying may look safe. The second situation exposes the risk of creating a duplicate.

Append this test to the same file:

def test_timeout_after_the_provider_creates_a_ticket():
    created_tickets = []

    def provider(request):
        created_tickets.append({"id": "T-123"})
        raise httpx.ReadTimeout("Response lost", request=request)

    with client_for(provider) as client:
        with pytest.raises(httpx.ReadTimeout):
            create_ticket(client, "Fix export", "task-42")

    assert created_tickets == [{"id": "T-123"}]

The fake records the effect before raising the timeout. This checks that the small connector exposes the uncertainty without silently sending a second request. The timeout is injected; this is not a test of HTTPX's timeout timer.

The application around that connector still needs its own test. Does it preserve a pending or unknown-delivery state? Can it look up the existing request? If it retries, is it using a guarantee the provider actually offers?

I covered that distinction in A Timeout Doesn't Tell You Whether the Write Happened. For an integration test, the useful step is making both situations reproducible.

Use a stateful fake when the workflow needs one

A handler that always returns the same response is enough for a parser test. A workflow involving several requests needs more.

A small fake provider can keep a collection of tickets, remember request keys and return a sequence of responses. That lets a test ask whether the application retrieves the ticket created earlier, handles a rate limit before succeeding, or stops when a cursor repeats.

I would give the fake only the guarantees the real provider documents. If the provider supports deduplication for a limited period, the fake should not remember keys forever. If it has no idempotency support, adding that behaviour to the fake hides a real limitation.

Build fixtures from the provider's documented examples and carefully selected test responses. Remove credentials and customer information before storing anything. When a provider changes a response, update the fixture after understanding the change; automatically accepting a new snapshot can erase the evidence of a broken integration.

Check the rest of the application too

The connector can behave correctly while the application around it still fails.

For the ticket workflow, I want an application-level test that starts with the customer's request, uses the real service and database code with the fake external provider, and checks the saved outcome.

That might establish that a successful ticket reference is stored against the right request, an exhausted retry is recorded and explained, or an uncertain write does not become a cheerful "Ticket created" message.

The same applies to a background worker. Returning an error from a connector proves little if the worker catches it and incorrectly marks the job as completed.

These tests can remain deterministic. The external service is substituted, while the application's own components work together.

Keep a smaller set of checks against the real provider

There is one limitation no amount of local testing removes: your fake and your connector can agree with each other while both misunderstand the actual API.

I would use three layers:

LayerQuestion it answers
Connector tests with a controlled transportDoes our request construction, parsing and error handling follow the expected contract?
Application tests with a fake providerDoes the complete workflow save and present the right outcome?
Provider sandbox or controlled live checksDo the important assumptions still match the actual service?

The real-provider checks should use test accounts and selected operations, with limits and cleanup for any records they create. A provider's sandbox is useful, but production permissions, configuration and behaviour can still differ. Confirm the relevant deployment settings as part of delivery.

For changing integrations, I would run these checks at agreed points such as a release, a provider-version change or a scheduled compatibility check. They do not all need to run on every pull request.

What I would include in the delivery

When I scope an integration, I want the work to include more than the request that succeeds in the demo.

The team should receive tests for the supported operations, a clear account of failure and recovery behaviour, configuration instructions and a way to check compatibility with the provider. The person using the application should be able to understand what happened and take a safe next step.

That is part of making a connection between systems usable in day-to-day work. It also makes future changes easier to review, because there is evidence of what the integration is expected to preserve.

If an existing application has connections that need this kind of attention, my application improvement work covers extending and strengthening those workflows.