Runtime

Mocking HTTP in Python CLI Tests with respx

Test httpx-based CLI commands without a network: respx routes and patterns, JSON and error responses, timeouts, asserting requests, sequences for retries, and CliRunner integration.

Updated

A CLI that talks to an API needs tests that do not. Real requests make tests slow, flaky and dependent on credentials and test data on a server you may not control — and they cannot easily produce the cases that matter most: a 500 in the middle of pagination, a timeout on the second retry, a 401 after a token expires. respx mocks httpx at the transport layer: your code builds real httpx.Request objects and goes through its real client, middleware and retry logic, but respx intercepts the request at the last moment and returns the response you scripted. This guide covers the patterns a CLI needs: routing by method, URL and parameters; JSON, error and timeout responses; asserting what was sent; scripted sequences for retries and pagination; and wiring it all into CliRunner tests. It belongs to the HTTP APIs topic.

Prerequisites

How respx intercepts requests

Where respx steps in The layers of an httpx request in a command line tool, with respx replacing only the transport at the bottom. Where respx steps in CLI command real parses options, prints results Client code real pagination, retries, raise_for_status, error mapping httpx.Client real base_url, headers, auth, timeouts Transport respx matched against routes; scripted response returned Everything above the transport runs for real; unmatched requests raise.

httpx sends every request through a transport. respx replaces the transport for the duration of a test, matches each outgoing request against the routes you defined, and returns the first matching route's response. Everything above the transport — your client configuration, headers, authentication hooks, raise_for_status(), retries — runs for real. By default, a request that matches no route raises an error, so a test can never silently reach the internet.

The recipe

The code under test is a small client with a CLI command on top:

# src/tickets/client.py
from __future__ import annotations

import httpx

API = "https://api.example.com"


class ApiError(Exception):
    pass


def make_client(token: str) -> httpx.Client:
    return httpx.Client(base_url=API, headers={"Authorization": f"Bearer {token}"}, timeout=10)


def list_tickets(client: httpx.Client, *, status: str = "open") -> list[dict]:
    tickets, url, params = [], "/tickets", {"status": status}
    while url:
        try:
            response = client.get(url, params=params)
        except httpx.TimeoutException:
            raise ApiError("the API did not respond in time") from None
        if response.status_code == 401:
            raise ApiError("not authorised; run `tickets login`")
        response.raise_for_status()
        body = response.json()
        tickets += body["items"]
        url, params = body.get("next"), None
    return tickets
# src/tickets/cli.py
import os

import typer

from tickets.client import ApiError, list_tickets, make_client

app = typer.Typer()


@app.callback()
def main() -> None:
    """Ticket tool."""


@app.command("list")
def list_cmd(status: str = "open") -> None:
    """List tickets."""
    try:
        with make_client(os.environ.get("TICKETS_TOKEN", "")) as client:
            tickets = list_tickets(client, status=status)
    except ApiError as exc:
        typer.echo(f"error: {exc}", err=True)
        raise typer.Exit(1)
    for t in tickets:
        typer.echo(f"#{t['id']} {t['title']}")

The tests use the respx_mock fixture, which activates respx for one test and asserts afterwards that every route you declared was called:

# tests/test_tickets.py
import httpx
import pytest
from typer.testing import CliRunner

from tickets.cli import app

runner = CliRunner()
BASE = "https://api.example.com"


def test_lists_tickets_across_pages(respx_mock):
    first = respx_mock.get(f"{BASE}/tickets", params={"status": "open"}).respond(
        json={"items": [{"id": 1, "title": "Login broken"}], "next": "/tickets?page=2"})
    second = respx_mock.get(f"{BASE}/tickets", params={"page": "2"}).respond(
        json={"items": [{"id": 2, "title": "Slow search"}], "next": None})
    result = runner.invoke(app, ["list"], env={"TICKETS_TOKEN": "t0k"})
    assert result.exit_code == 0
    assert result.stdout == "#1 Login broken\n#2 Slow search\n"
    assert first.calls.last.request.headers["Authorization"] == "Bearer t0k"
    assert second.call_count == 1


def test_unauthorised_gives_a_hint(respx_mock):
    respx_mock.get(f"{BASE}/tickets").respond(401)
    result = runner.invoke(app, ["list"])
    assert result.exit_code == 1 and "run `tickets login`" in result.stderr


def test_timeout_is_a_clean_error(respx_mock):
    respx_mock.get(f"{BASE}/tickets").mock(side_effect=httpx.ReadTimeout("slow"))
    result = runner.invoke(app, ["list"])
    assert "did not respond in time" in result.stderr


def test_server_error_propagates(respx_mock):
    respx_mock.get(f"{BASE}/tickets").respond(503)
    result = runner.invoke(app, ["list"])
    assert isinstance(result.exception, httpx.HTTPStatusError)
respx catching a stray request Terminal output of a respx-mocked test failing because the code made an HTTP request that no route matched. respx catching a stray request bash $ pytest -q tests/test_tickets.py E respx.models.AllMockedAssertionError: E RESPX: <Request('GET', 'https://api.example.com/me')> not mocked! # a new request appeared in the code path — decide on purpose whether it belongs No test can silently reach the internet.

Routes and patterns

A route is a pattern plus a response. Patterns can match the method (respx_mock.get, .post), the full URL or parts of it (host=, path=, path__regex=), query parameters (params= — matched as a subset unless you ask for exact), headers and JSON bodies (json=). Routes are checked in the order they were added, so put specific routes before general ones. respx_mock.route(...) builds a route from any combination of patterns when the shortcut methods are not enough.

Responses and side effects

.respond(status, json=..., text=..., headers=...) returns a fixed response. .mock(side_effect=...) is more flexible: an exception instance raises that exception (perfect for httpx.ConnectError and timeouts), a list returns its items one call at a time (sequences), and a function receives the request and returns a response — useful when the response depends on the request body.

Asserting what was sent

Each route records its calls. route.called, route.call_count and route.calls.last.request let you assert on method, URL, headers and body — which is how the first test proves the token reached the API. Assert on what matters for the behaviour (authentication, the query that selects data) rather than every header.

Sequences for retries

Retry logic is where scripted sequences shine. Give a route a list as its side effect, and each call takes the next item:

def test_retry_after_503(respx_mock):
    route = respx_mock.get(f"{BASE}/tickets").mock(side_effect=[
        httpx.Response(503),
        httpx.Response(200, json={"items": [], "next": None}),
    ])
    ...
    assert route.call_count == 2

If the code retries more often than the list allows, respx raises on the extra call — so a retry loop that never stops fails the test instead of hanging it. Combine this with the backoff design in retries and backoff for CLI HTTP calls, and patch the sleep function so tests do not actually wait.

Scripting responses Ways to script respx route responses and what each is used for when testing a command line tool. Scripting responses Side effect Produces Use for .respond(json=…) a fixed response happy paths side_effect=ReadTimeout a raised exception timeouts, offline side_effect=[r1, r2] one response per call retries, pagination side_effect=func response from the request echoing bodies A list that runs out turns an endless retry loop into a test failure.

Scaling up: base URLs and payload files

Repeating https://api.example.com in every route gets old, and hand-written response dictionaries drift from what the API really returns. Two small conventions fix both. The respx pytest marker sets a base URL for the respx_mock fixture, so routes use paths only; and a payloads/ directory of JSON files, captured from the real API once and trimmed, gives tests realistic data:

# tests/test_with_payloads.py
import json
from pathlib import Path

import pytest
from typer.testing import CliRunner

from tickets.cli import app

PAYLOADS = Path(__file__).parent / "payloads"


def payload(name: str) -> dict:
    return json.loads((PAYLOADS / f"{name}.json").read_text(encoding="utf-8"))


@pytest.mark.respx(base_url="https://api.example.com")
def test_open_tickets_from_recorded_payload(respx_mock):
    respx_mock.get("/tickets").respond(json=payload("tickets_open"))
    assert CliRunner().invoke(app, ["list"]).stdout == "#7 From fixture\n"

Name payload files after the request they answer (tickets_open.json, ticket_42.json), keep them small — a couple of items is enough to exercise formatting and pagination — and scrub tokens, emails and internal hostnames before committing them. When the API adds fields, refreshing a payload file is a one-line change reviewed like any other diff, and tests that depended on the old shape fail where the assumption lives.

UX considerations

Tests are for maintainers, so optimise for readable failures:

  • Use realistic payloads. Copy a real (anonymised) response into a fixture file; it catches assumptions about field names that hand-written dictionaries never will.
  • Keep the "no unmatched requests" default. Turning it off with assert_all_mocked=False hides exactly the bugs these tests exist for.
  • Name tests by scenario — test_unauthorised_gives_a_hint — so a failure tells you which user-visible behaviour broke.
  • Inject the client. Commands that build their client from a function or context, as here, are easy to test; clients created at import time are not — see avoiding import-time side effects.

Testing the behaviour

The suite above is the behaviour; two checks confirm it is doing its job. First, add a stray request to the code — say, a GET /me before listing — and confirm every test fails with respx's "not mocked" error naming the URL. Second, make the pagination loop ignore next; the multi-page test fails because the second route was never called, reported by the fixture's automatic assert_all_called check. Both failures are clear, immediate and offline — which is the point.

Conclusion

respx lets a CLI's HTTP code run for real right up to the network: match requests by method, URL, parameters and body; respond with JSON, status codes or exceptions; assert on the requests that were sent; and script sequences for pagination and retries. Use the respx_mock fixture with CliRunner to test commands end to end, keep unmatched requests failing, and base payloads on real responses.

Frequently asked questions

Does respx work with requests?

No — respx is for httpx. For requests, the responses library plays the same role. If your CLI uses both, it is usually worth standardising on one client.

How do I test async code that uses httpx.AsyncClient?

The same routes work; mark the test async (with pytest-asyncio or anyio) and await your code. respx intercepts both sync and async transports.

Can respx record real responses for later replay?

Not itself. Capture real responses once with a small script (or VCR-style tools for httpx), store them as fixtures, and load them into routes. Re-capture when the API changes.

Should every test mock HTTP?

Most should. Keep a small, separate suite — run on a schedule or before releases — that hits a real staging API, to catch contract changes that mocks cannot see.

How do I check the JSON body my CLI sends?

Match on it with respx_mock.post("/tickets", json={"title": "New"}), so only a request with that body matches, or inspect route.calls.last.request.content after the call and json.loads it. Matching is stricter; inspecting gives a clearer failure message when a field is wrong.