Fully async mock HTTP server for pytest — test HTTP clients against a real TCP server on localhost, with rich request matching and response control.
An application that calls a payment API:
# my_app/settings.py
PAYMENT_URL = "https://api.payments.example.com"# my_app/payment.py
import aiohttp
from my_app import settings
async def charge(amount: int) -> dict:
"""Call the external payment API and return the response JSON."""
async with aiohttp.ClientSession() as session:
async with session.post(
f"{settings.PAYMENT_URL}/charge",
json={"amount": amount},
) as resp:
resp.raise_for_status()
return await resp.json()Set up a fixture that redirects the URL to the mock server:
# tests/conftest.py
import pytest_asyncio
from my_app import settings
@pytest_asyncio.fixture
async def payment_mock(http_server, monkeypatch):
mock = await http_server()
monkeypatch.setattr(settings, "PAYMENT_URL", mock.base_url)
yield mock# tests/test_payment.py
from my_app.payment import charge
async def test_charge_sends_correct_payload(payment_mock):
# Arrange
handler = payment_mock.expect_request("/charge", method="POST")
handler.respond_with_json({"transaction_id": "tx-42"}, status=201)
# Act
result = await charge(amount=100)
# Assert
assert result["transaction_id"] == "tx-42"
handler.call_log.assert_called_once()
handler.call_log.assert_called_with(json={"amount": 100})http_server is a factory fixture — call it once per external service you want to mock:
# tests/conftest.py
import pytest_asyncio
from my_app import settings
@pytest_asyncio.fixture
async def payment_mock(http_server, monkeypatch):
mock = await http_server()
monkeypatch.setattr(settings, "PAYMENT_URL", mock.base_url)
yield mockmock.base_url contains the server address, e.g. http://127.0.0.1:54321/.
monkeypatch.setattr injects the mock server url instead of the real one for the duration of the test
handler = mock.expect_request("/api/users", method="POST")
handler.respond_with_json({"id": 1}, status=201)expect_request returns a RequestHandler. Use it to configure the response and later inspect what was called.
handler.respond_with_json({"users": []})
handler.respond_with_json({"error": "not found"}, status=404)
handler.respond_with_json({"ok": True}, headers={"X-Request-Id": "abc"})handler.respond_with_data("pong")
handler.respond_with_data("<root/>", content_type="application/xml", status=200)
handler.respond_with_data("", status=204)Pass any pre-built aiohttp.web.Response:
from aiohttp.web import Response, json_response
handler.respond_with_response(Response(status=202, text="accepted"))
handler.respond_with_response(json_response({"id": 1}, status=201))For dynamic responses that inspect the incoming request:
async def my_handler(request):
body = await request.json()
return json_response({"echo": body["message"]})
handler.respond_with_handler(my_handler)Sync handlers work too:
def my_handler(request):
return Response(text="ok")
handler.respond_with_handler(my_handler)Return different responses on successive calls. The last item persists for all subsequent calls. Useful for testing retry logic:
handler.respond_with_sequence([
(503, {"error": "unavailable"}), # 1st call
(503, {"error": "unavailable"}), # 2nd call
(200, {"data": "ready"}), # 3rd+ calls (repeats)
])Accepts web.Response objects, (status, json) tuples, and callables — or a mix:
from aiohttp.web import Response
handler.respond_with_sequence([
Response(status=429, text="rate limited"),
(200, {"result": "ok"}),
])All matching parameters are passed to expect_request as keyword arguments.
Exact string:
mock.expect_request("/api/users")Regular expression:
import re
mock.expect_request(re.compile(r"^/api/users/\d+$"))Starts with a prefix:
from async_pytest_httpserver import StartsWith
mock.expect_request(path=StartsWith("/api/"))Contains a substring:
from async_pytest_httpserver import Contains
mock.expect_request(path=Contains("/users/"))Single method:
mock.expect_request("/resource", method="POST")List of methods:
mock.expect_request("/resource", method=["GET", "HEAD"])Any method:
mock.expect_request("/health", method="*")As a dict (parameter order doesn't matter, extra parameters in the request are ignored):
mock.expect_request("/search", query_string={"q": "python", "page": "1"})
# Matches /search?page=1&q=python
# Also matches /search?q=python&page=1&source=google (extra params ignored)As a raw string:
mock.expect_request("/items", query_string="sort=asc&limit=10")All specified headers must be present with the given values:
mock.expect_request("/secure", headers={"X-Api-Key": "secret"})Custom comparison function (header_name, actual, expected) -> bool:
def starts_with_bearer(name, actual, expected):
return actual.startswith(expected)
mock.expect_request(
"/auth",
headers={"Authorization": "Bearer "},
header_value_matcher=starts_with_bearer,
)Exact match:
mock.expect_request("/users", method="POST", json={"name": "Alice"})Match requests whose body contains the given keys — recursive subset check:
mock.expect_request(
"/events",
method="POST",
json_contains={"user": {"name": "Alice"}},
)
# Matches {"user": {"name": "Alice", "role": "admin"}, "meta": {...}}mock.expect_request("/upload", data=b"\xff\xd8\xff") # bytes
mock.expect_request("/text", data="hello world") # stringM is an alias for RequestMatcher. Combine matchers with & (AND), | (OR), ~ (NOT):
from async_pytest_httpserver import M, StartsWith
# OR — match either path
mock.expect_request(
M(path="/api/v1", method="GET") | M(path="/api/v2", method="GET")
)
# AND — path prefix AND required header
mock.expect_request(
M(path=StartsWith("/api/"), method="GET") & M(headers={"X-Auth": "token"})
)
# NOT — exclude health check path
mock.expect_request(~M(path=StartsWith("/health")), method="GET")
# Complex combinations
mock.expect_request(
(M(path="/v1", method="*") | M(path="/v2", method="*"))
& M(method="POST")
)expect_request — responds to every matching request indefinitely.
handler = mock.expect_request("/api", method="GET")
handler.respond_with_json({"ok": True})expect_oneshot_request — responds exactly once. Subsequent calls get 404.
handler = mock.expect_oneshot_request("/init", method="POST")
handler.respond_with_json({"initialized": True})
r1 = await client.post(mock.url_for("/init")) # 200
r2 = await client.post(mock.url_for("/init")) # 404 — handler is gone
handler.call_log.assert_called_once()Call mock.check() at the end of a test to assert that all oneshot handlers were actually consumed:
mock.check() # raises if any oneshot/ordered handlers were never calledexpect_ordered_request — handlers must be invoked in registration order. Violating the order returns 500.
h1 = mock.expect_ordered_request("/step1", method="POST")
h1.respond_with_json({"step": 1})
h2 = mock.expect_ordered_request("/step2", method="POST")
h2.respond_with_json({"step": 2})
await client.post(mock.url_for("/step1"))
await client.post(mock.url_for("/step2"))
mock.check() # passes — both consumed in orderWhen two handlers are registered for overlapping routes, ConflictPolicy controls which one wins or whether registration raises an error.
Pass conflict_policy to the http_server factory:
from async_pytest_httpserver import ConflictPolicy
mock = await http_server(conflict_policy=ConflictPolicy.LAST_WINS)The most recently registered handler takes priority. Earlier handlers remain in the pool as fallbacks for non-overlapping requests.
mock = await http_server()
mock.expect_request("/api", method="GET").respond_with_json({"v": 1})
mock.expect_request("/api", method="GET").respond_with_json({"v": 2})
resp = await client.get(mock.url_for("/api"))
data = await resp.json()
assert data == {"v": 2}The first registered handler keeps priority. Later registrations for the same route are checked after the first one.
mock = await http_server(conflict_policy=ConflictPolicy.FIRST_WINS)
mock.expect_request("/api", method="GET").respond_with_json({"v": 1})
mock.expect_request("/api", method="GET").respond_with_json({"v": 2})
resp = await client.get(mock.url_for("/api"))
data = await resp.json()
assert data == {"v": 1}Raises ConflictError at registration time if the new handler overlaps with an already-registered one in the same pool. Useful for catching accidental duplicate registrations.
from async_pytest_httpserver import ConflictPolicy, ConflictError
mock = await http_server(conflict_policy=ConflictPolicy.ERROR)
mock.expect_request("/api", method="GET").respond_with_json({"v": 1})
# Raises ConflictError — same path and method already registered
mock.expect_request("/api", method="GET").respond_with_json({"v": 2})Overlap detection is field-by-field: two matchers are considered non-overlapping when at least one dimension is provably incompatible (different exact paths, disjoint methods, different concrete query or header values, different JSON bodies). Conservative matchers (StartsWith, Contains, regex, json_contains=, composites) are treated as potentially overlapping.
Cross-pool registrations — e.g. an oneshot handler alongside a permanent one for the same route — are allowed regardless of policy.
Every handler tracks its calls in .call_log:
handler.call_log.assert_called() # at least once
handler.call_log.assert_called_once() # exactly once
handler.call_log.assert_not_called() # never
handler.call_log.assert_call_count(3) # exactly 3 timesCheck what was sent with assert_called_with. All parameters are keyword-only and optional — only specified fields are verified:
handler.call_log.assert_called_with(json={"name": "Alice"})
handler.call_log.assert_called_with(query={"page": "2"})
handler.call_log.assert_called_with(headers={"X-Api-Key": "secret"})
handler.call_log.assert_called_with(text="hello")
# Check a specific call by index (-1 = last, 0 = first)
handler.call_log.assert_called_with(call_index=0, json={"name": "Alice"})
handler.call_log.assert_called_with(call_index=1, json={"name": "Bob"})Access CallInfo objects directly:
call = handler.call_log[0] # first
call = handler.call_log[-1] # last
call = handler.call_log.last # last (raises if empty)
call.method # "POST"
call.path # "/api/users"
call.headers # CIMultiDictProxy (case-insensitive header access)
call.query # MultiDictProxy — e.g. call.query["page"]
call.json # parsed body for application/json requests
call.text # decoded body for text/plain requests
call.data # raw bytes for all other content typesIterate all calls:
for call in handler.call_log:
assert call.json["amount"] > 0mock.log records every request across all handlers as (CallInfo, Response) pairs:
assert len(mock.log) == 3
call, response = mock.log[0]
assert call.path == "/api/users"Query the log using any matcher:
from async_pytest_httpserver import M, StartsWith
mock.assert_request_made(
M(path=StartsWith("/api/"), method="POST"),
count=2,
)
for call, response in mock.iter_matching_requests(M(path="/api/users")):
print(call.json)mock.check() asserts at the end of a test that:
- no exceptions were raised inside handlers
- all oneshot handlers were consumed
- all ordered handlers were consumed
To also verify that permanent handlers were called at least once:
mock.check(all_called=True)Inspect handler errors explicitly:
mock.check_handler_errors() # re-raises first exception from a handlerPost-hooks transform the response after it's built. Attach with with_post_hook() — returns self for chaining:
handler.with_post_hook(Delay(ms=500)).respond_with_json({"slow": True})Multiple hooks are applied in registration order.
Pause before sending the response — useful for testing timeouts or loading states:
from async_pytest_httpserver import Delay
handler.with_post_hook(Delay(ms=500)) # wait 500ms
# or
handler.with_post_hook(Delay(sec=0.5)) # wait 500msCorrupt the response body with random bytes — for testing error-handling code:
from async_pytest_httpserver import Garbage
handler.with_post_hook(Garbage(prefix_size=4, suffix_size=2))
# Prepends 4 random bytes, appends 2 random bytesCompose multiple hooks in sequence:
from async_pytest_httpserver import Chain, Delay, Garbage
handler.with_post_hook(Chain(Delay(ms=100), Garbage(suffix_size=3)))Any callable matching (request, response) -> response works, sync or async:
async def add_trace_header(request, response):
response.headers["X-Trace-Id"] = request.headers.get("X-Request-Id", "")
return response
handler.with_post_hook(add_trace_header)Build a full URL for a path:
mock.url_for("/api/users/42")
# "http://127.0.0.1:54321/api/users/42"Remove all handlers and reset call history. The server keeps running:
mock.clear()Useful for shared fixtures that run multiple scenarios:
async def test_two_scenarios(mock):
mock.expect_request("/api").respond_with_json({"v": 1})
# ... first scenario ...
mock.clear()
mock.expect_request("/api").respond_with_json({"v": 2})
# ... second scenario ...Human-readable summary of all registered handlers — for debugging:
print(mock.format_matchers())
# Registered handlers:
# [PERMANENT] RequestMatcher(path='/api/users', method='GET') — 2 call(s)
# [ONESHOT] RequestMatcher(path='/init', method='POST') — 0 call(s)Create a pre-configured wrapper with shared defaults. Defaults can be overridden per-call:
# All handlers on this mock use POST and require the auth header
api = mock.bake(method="POST", headers={"Authorization": "Bearer token"})
api.expect_request("/users").respond_with_json({"id": 1})
api.expect_request("/orders").respond_with_json({"id": 2})
# Override per-call — this handler uses GET
api.expect_request("/health", method="GET").respond_with_json({"ok": True})examples/aiohttp_example/ is a self-contained project that demonstrates every feature of the library through realistic tests.
examples/aiohttp_example/
app/
clients.py # WeatherClient, NotificationClient — real HTTP clients
services.py # Services that read URLs from config (for injection demos)
config.py # Module-level URL config (env-var based)
tests/
conftest.py # Fixtures: mock, weather_mock, notify_mock, client
test_responses.py # All response types: json, data, handler, sequence
test_matching.py # All matching: path, method, query, headers, body, M()
test_assertions.py # CallLog, server log, check(), assert_request_made
test_lifetime.py # Permanent, oneshot, ordered, sequences, bake()
test_hooks.py # Delay, Garbage, Chain, custom hooks
test_url_injection.py # URL injection via monkeypatch (3 patterns)
cd examples/aiohttp_example
uv sync # creates .venv and installs the library from source
make test # run all testsCall http_server() multiple times in one fixture to mock several external services independently:
@pytest_asyncio.fixture
async def services(http_server, monkeypatch):
payments = await http_server()
notifications = await http_server()
monkeypatch.setattr(settings, "PAYMENT_URL", payments.base_url)
monkeypatch.setattr(settings, "NOTIFY_URL", notifications.base_url)
yield payments, notifications
async def test_checkout(client, services):
# Arrange
payments, notifications = services
payments.expect_request("/charge", method="POST").respond_with_json({"ok": True})
notifications.expect_request("/send", method="POST").respond_with_json({"ok": True})
# Act
await client.post(f"{settings.CHECKOUT_URL}/buy", json={"item_id": 1})
# Assert
payments.check()
notifications.check()