HttpPlugin Guide¶
HttpPlugin intercepts HTTP calls made through httpx (sync and async), requests, urllib, and aiohttp (if installed). It requires the bigfoot[http] extra. For aiohttp support, also install bigfoot[aiohttp].
Installation¶
pip install bigfoot[http] # httpx, requests, urllib
pip install bigfoot[aiohttp] # + aiohttp support
pip install bigfoot[http,aiohttp] # both
bigfoot[http] installs httpx>=0.25.0 and requests>=2.31.0. bigfoot[aiohttp] installs aiohttp>=3.9.0.
Setup¶
In pytest, access HttpPlugin through the bigfoot.http proxy. It auto-creates the plugin for the current test on first use — no explicit instantiation needed:
import bigfoot
def test_api():
bigfoot.http.mock_response("GET", "https://api.example.com/users", json={"users": []})
with bigfoot:
import httpx
response = httpx.get("https://api.example.com/users")
bigfoot.http.assert_request("GET", "https://api.example.com/users",
headers=IsMapping(), body=None)
For manual use outside pytest, construct HttpPlugin explicitly:
from bigfoot import StrictVerifier
from bigfoot.plugins.http import HttpPlugin
verifier = StrictVerifier()
http = HttpPlugin(verifier)
Each verifier may have at most one HttpPlugin. A second HttpPlugin(verifier) raises ValueError.
Registering mock responses¶
Use bigfoot.http.mock_response(method, url, ...) to register a response before entering the sandbox:
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
method |
str |
required | HTTP method, case-insensitive ("GET", "POST", etc.) |
url |
str |
required | Full URL to match, including scheme and host |
json |
object |
None |
Response body serialized as JSON; sets content-type: application/json |
body |
str \| bytes \| None |
None |
Raw response body; mutually exclusive with json |
status |
int |
200 |
HTTP status code |
headers |
dict[str, str] \| None |
None |
Additional response headers |
params |
dict[str, str] \| None |
None |
Query parameters that must be present in the request URL |
required |
bool |
True |
Whether an unused mock causes UnusedMocksError at teardown |
json and body are mutually exclusive; providing both raises ValueError.
FIFO ordering¶
Multiple mock_response() calls for the same method+URL are consumed in registration order. The first matching request gets the first registered response, and so on. If a request arrives with no matching mock remaining, UnmockedInteractionError is raised.
bigfoot.http.mock_response("GET", "https://api.example.com/token", json={"token": "first"})
bigfoot.http.mock_response("GET", "https://api.example.com/token", json={"token": "second"})
Optional responses¶
Mark a mock response as optional with required=False:
bigfoot.http.mock_response("GET", "https://api.example.com/health", json={"ok": True}, required=False)
An optional mock that is never triggered does not cause UnusedMocksError at teardown.
URL matching¶
bigfoot matches on scheme, host, path, and (if params is provided) query parameters. Query parameters in the actual URL that are not listed in params are ignored.
# Matches https://api.example.com/search?q=hello&page=2 if params={"q": "hello"}
bigfoot.http.mock_response("GET", "https://api.example.com/search", json={...}, params={"q": "hello"})
Asserting HTTP interactions¶
Use bigfoot.http.assert_request() to assert interactions after the sandbox exits:
import bigfoot, httpx
def test_users():
bigfoot.http.mock_response("GET", "https://api.example.com/users", json=[])
with bigfoot:
response = httpx.get("https://api.example.com/users")
bigfoot.http.assert_request("GET", "https://api.example.com/users",
headers=IsMapping(), body=None)
assert_request() requires all assertable request fields. Omitting any of method, url, headers, or body raises MissingAssertionFieldsError. Use IsMapping() from dirty-equals for headers when you want to assert type without exact matching, or ANY from unittest.mock.
assert_request() is a convenience wrapper around the lower-level verifier.assert_interaction() call:
# Convenience (recommended):
bigfoot.http.assert_request("GET", "https://api.example.com/users",
headers=IsMapping(), body=None)
# Equivalent low-level call:
bigfoot.assert_interaction(bigfoot.http.request, method="GET", url="https://api.example.com/users",
request_headers=IsMapping(), request_body=None)
Parameters for assert_request():
| Parameter | Description |
|---|---|
method |
HTTP method, uppercase |
url |
Full URL as received |
headers |
Request headers dict |
body |
Request body decoded as UTF-8 |
Using with httpx sync¶
import bigfoot, httpx
def test_httpx_sync():
bigfoot.http.mock_response("GET", "https://api.example.com/data", json={"value": 42})
with bigfoot:
response = httpx.get("https://api.example.com/data")
assert response.status_code == 200
assert response.json() == {"value": 42}
bigfoot.http.assert_request("GET", "https://api.example.com/data",
headers=IsMapping(), body=None)
Using with httpx async¶
import bigfoot, httpx
async def test_httpx_async():
bigfoot.http.mock_response("POST", "https://api.example.com/items", json={"id": 1}, status=201)
async with bigfoot:
async with httpx.AsyncClient() as client:
response = await client.post("https://api.example.com/items", json={"name": "widget"})
assert response.status_code == 201
bigfoot.http.assert_request("POST", "https://api.example.com/items",
headers=IsMapping(), body=None)
Using with requests¶
import bigfoot, requests
def test_requests():
bigfoot.http.mock_response("DELETE", "https://api.example.com/items/99", status=204)
with bigfoot:
response = requests.delete("https://api.example.com/items/99")
assert response.status_code == 204
bigfoot.http.assert_request("DELETE", "https://api.example.com/items/99",
headers=IsMapping(), body=None)
UnmockedInteractionError for HTTP¶
When HTTP code fires a request with no matching mock, bigfoot raises UnmockedInteractionError with a hint:
Unexpected HTTP request: GET https://api.example.com/data
To mock this request, add before your sandbox:
bigfoot.http.mock_response("GET", "https://api.example.com/data", json={...})
Or to mark it optional:
bigfoot.http.mock_response("GET", "https://api.example.com/data", json={...}, required=False)
ConflictError¶
At sandbox entry, HttpPlugin checks whether httpx.HTTPTransport.handle_request, httpx.AsyncHTTPTransport.handle_async_request, and requests.adapters.HTTPAdapter.send have already been patched by another library. If any of these have been modified by a third party (respx, responses, httpretty, or an unknown library), bigfoot raises ConflictError:
Nested bigfoot sandboxes use reference counting and do not conflict with each other.
Pass-Through: Real HTTP Calls¶
bigfoot.http.pass_through(method, url) registers a permanent routing rule. When an incoming request matches the rule and no mock response matches first, the real HTTP call is made through the original transport (bypassing bigfoot's interception layer). The interaction is still recorded on the timeline and must be asserted like any other interaction.
Pass-through rules are routing hints, not assertions. An unused pass-through rule does not raise UnusedMocksError at teardown.
import bigfoot, httpx
def test_mixed():
bigfoot.http.mock_response("GET", "https://api.example.com/cached", json={"data": "cached"})
bigfoot.http.pass_through("GET", "https://api.example.com/live")
with bigfoot:
mocked = httpx.get("https://api.example.com/cached") # returns mock response
real = httpx.get("https://api.example.com/live") # makes real HTTP call
bigfoot.http.assert_request("GET", "https://api.example.com/cached",
headers=IsMapping(), body=None)
bigfoot.http.assert_request("GET", "https://api.example.com/live",
headers=IsMapping(), body=None)
Mock responses are checked before pass-through rules. If a mock matches, the pass-through rule is not evaluated for that request. If no mock matches and a pass-through rule matches, the real call is made. If neither matches, UnmockedInteractionError is raised.
Requiring response assertions¶
By default, assert_request() asserts only the four request fields (method, url, request_headers, request_body) and returns None. The require_response feature changes this behavior so that assert_request() returns an HttpAssertionBuilder that must be completed with a chained .assert_response() call. This ensures all seven fields (four request + three response) are always asserted.
Enabling via configuration¶
Add to your pyproject.toml:
With this setting, every assert_request() call returns an HttpAssertionBuilder:
import bigfoot, httpx
def test_api_with_response():
bigfoot.http.mock_response("GET", "https://api.example.com/users", json={"users": []})
with bigfoot:
response = httpx.get("https://api.example.com/users")
bigfoot.http.assert_request("GET", "https://api.example.com/users") \
.assert_response(200, {"content-type": "application/json"}, '{"users": []}')
Enabling via constructor¶
Pass require_response=True when constructing the plugin manually:
from bigfoot import StrictVerifier
from bigfoot.plugins.http import HttpPlugin
verifier = StrictVerifier()
http = HttpPlugin(verifier, require_response=True)
Per-call override¶
The require_response parameter on assert_request() overrides both the constructor default and the project-level config:
# Force response assertion for this call, regardless of project config:
bigfoot.http.assert_request("GET", "https://api.example.com/data", require_response=True) \
.assert_response(200, {}, '{"value": 42}')
# Disable response assertion for this call, even if project config enables it:
bigfoot.http.assert_request("GET", "https://api.example.com/health", require_response=False)
HttpAssertionBuilder¶
When require_response is active, assert_request() returns an HttpAssertionBuilder. This builder is lazy: it records the expected request fields but does not touch the timeline until assert_response() is called.
assert_response(status, headers, body) finalizes the assertion by calling verifier.assert_interaction() with all seven fields:
builder = bigfoot.http.assert_request("POST", "https://api.example.com/items",
headers={"content-type": "application/json"},
body='{"name": "widget"}',
require_response=True)
builder.assert_response(201, {"content-type": "application/json"}, '{"id": 1}')
Configuration via pyproject.toml¶
See the Configuration Guide for full details on [tool.bigfoot.http].
Using with aiohttp¶
Requires bigfoot[aiohttp]. If aiohttp is not installed, HttpPlugin works normally for other transports.
import bigfoot, aiohttp
async def test_aiohttp_get():
bigfoot.http.mock_response("GET", "https://api.example.com/data", json={"value": 42})
async with bigfoot:
async with aiohttp.ClientSession() as session:
response = await session.get("https://api.example.com/data")
assert response.status == 200
body = await response.json()
assert body == {"value": 42}
bigfoot.http.assert_request("GET", "https://api.example.com/data",
headers={}, body="",
require_response=True) \
.assert_response(200, {"content-type": "application/json"}, '{"value": 42}')
aiohttp POST with JSON body:
async def test_aiohttp_post():
bigfoot.http.mock_response("POST", "https://api.example.com/items",
json={"id": 1}, status=201)
async with bigfoot:
async with aiohttp.ClientSession() as session:
response = await session.post("https://api.example.com/items",
json={"name": "widget"})
assert response.status == 201
bigfoot.http.assert_request("POST", "https://api.example.com/items",
headers={}, body='{"name": "widget"}',
require_response=True) \
.assert_response(201, {"content-type": "application/json"}, '{"id": 1}')
The fake aiohttp response supports response.status, await response.json(), await response.text(), await response.read(), response.headers, and async with session.get(...) as response: context manager usage.
What HttpPlugin patches¶
When the sandbox activates, HttpPlugin installs class-level patches on:
httpx.HTTPTransport.handle_request(sync httpx)httpx.AsyncHTTPTransport.handle_async_request(async httpx)requests.adapters.HTTPAdapter.send(requests library)urllib.requestopener (urllib)aiohttp.ClientSession._request(aiohttp, if installed)asyncio.BaseEventLoop.run_in_executor(propagates ContextVar to thread pool executors)
All patches are reference-counted. Nested sandboxes increment/decrement the count; the actual method replacement only happens at count transitions from 0 to 1 and from 1 to 0.
The run_in_executor patch ensures the active-verifier ContextVar is copied into threads spawned by asyncio.run_in_executor, so HTTP calls made from thread pools are intercepted correctly.