Skip to main content

Async mock HTTP server for pytest, built on top of aiohttp.

Project description

async-pytest-httpserver

PyPI PyPI Downloads

Fully async mock HTTP server for pytest — test HTTP clients against a real TCP server on localhost, with rich request matching and response control.

Quick start

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})

Functionality

Fixture setup

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 mock

mock.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

Registering handlers

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.

Response types

respond_with_json

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"})

respond_with_data

handler.respond_with_data("pong")
handler.respond_with_data("<root/>", content_type="application/xml", status=200)
handler.respond_with_data("", status=204)

respond_with_response

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))

respond_with_handler

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)

respond_with_sequence

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"}),
])

Request matching

All matching parameters are passed to expect_request as keyword arguments.

Path

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/"))

Method

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="*")

Query string

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")

Headers

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,
)

JSON body

Exact match:

mock.expect_request("/users", method="POST", json={"name": "Alice"})

Partial JSON matching

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": {...}}

Raw data

mock.expect_request("/upload", data=b"\xff\xd8\xff")  # bytes
mock.expect_request("/text", data="hello world")      # string

Combinators

M 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")
)

Handler lifetime

Permanent

expect_request — responds to every matching request indefinitely.

handler = mock.expect_request("/api", method="GET")
handler.respond_with_json({"ok": True})

Oneshot

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 called

Ordered

expect_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 order

Conflict policy

When 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)

LAST_WINS (default)

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}

FIRST_WINS

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}

ERROR

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.

Assertions

CallLog

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 times

Check 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 types

Iterate all calls:

for call in handler.call_log:
    assert call.json["amount"] > 0

Server-level log

mock.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)

check()

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 handler

Hooks

Post-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.

Delay

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 500ms

Garbage

Corrupt 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 bytes

Chain

Compose multiple hooks in sequence:

from async_pytest_httpserver import Chain, Delay, Garbage

handler.with_post_hook(Chain(Delay(ms=100), Garbage(suffix_size=3)))

Custom hook

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)

Utilities

url_for()

Build a full URL for a path:

mock.url_for("/api/users/42")
# "http://127.0.0.1:54321/api/users/42"

clear()

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 ...

format_matchers()

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)

bake()

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})

Example project

examples/aiohttp_example/ is a self-contained project that demonstrates every feature of the library through realistic tests.

Structure

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)

Running

cd examples/aiohttp_example
uv sync    # creates .venv and installs the library from source
make test  # run all tests

Multiple mock servers

Call 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()

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

async_pytest_httpserver-2.1.0.tar.gz (271.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

async_pytest_httpserver-2.1.0-py3-none-any.whl (21.5 kB view details)

Uploaded Python 3

File details

Details for the file async_pytest_httpserver-2.1.0.tar.gz.

File metadata

File hashes

Hashes for async_pytest_httpserver-2.1.0.tar.gz
Algorithm Hash digest
SHA256 b3e867b6b197f990efee82366e86f6d6336cdb02b71406f2505b3b7ef9096449
MD5 baaff2d4d5e71b51560075cf6c532c2c
BLAKE2b-256 219465b857c8517ce2c1e9ded5daded92e714ca80e9cebd1e86d81d91fd61fd5

See more details on using hashes here.

File details

Details for the file async_pytest_httpserver-2.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for async_pytest_httpserver-2.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 9c13abe1dc5e2ba717dafb1f83af2a698b967763a24a3c70d174d7c433e66680
MD5 77b49cfdc71042ed8d881a34c1cfb079
BLAKE2b-256 5a4f3e7bab4bcbf9d58a3efbca1234bb7dab237a19d22895f6f6c568b33ee8f4

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page