blackbull.testing¶
blackbull.testing
¶
Test clients for BlackBull applications — three instruments, three layers.
BlackBull threads a typed :class:~blackbull.connection.Connection end to
end and keeps the ASGI scope dict at two boundaries only. That is why
there is more than one test client here, and why picking the right one
matters: each drives a different layer, and a defect on one is invisible to
the others.
============================ =============================================
Instrument What it exercises
============================ =============================================
:mod:blackbull.testing.native Application logic — routing, middleware,
handlers, DI, events — through the native
app(conn, receive, send) entry point that
every production request takes. The default
choice for everyday tests.
:class:~blackbull.testing.native.NativeTestServer
The full stack on a real loopback socket:
protocol parsing, framing, keep-alive,
connection lifecycle, wire bytes.
:class:TestClient The ASGI compatibility boundary — the
as_scope() / from_scope() round-trip,
driven the way an external ASGI host
(uvicorn, httpx.ASGITransport) drives it.
============================ =============================================
:class:TestClient is deliberately not the default. It reaches the app
through httpx.ASGITransport → ASGI scope dict → from_scope(), so the
isinstance(conn, Connection) branch of BlackBull.__call__ is never
taken by it. What it uniquely covers is the conversion chain itself: a
missing _CONNECTION_FIELDS entry or a from_scope coercion bug shows
up here and nowhere else in the suite, which is exactly why it stays.
TestClient usage — a boundary-conformance instrument::
from blackbull import BlackBull
from blackbull.testing import TestClient
app = BlackBull()
@app.route('/')
async def hello():
return "hi"
def test_asgi_boundary():
with TestClient(app) as client:
response = client.get('/')
assert response.status_code == 200
assert response.text == "hi"
The client is a context manager so that ASGI lifespan.startup runs
before any request and lifespan.shutdown runs on exit. Apps that
don't implement the lifespan protocol are tolerated silently.
For everything else, start from docs/guide/testing.md.
NativeClient
¶
Synchronous Tier 1 client, for tests written as plain def.
Owns one background event loop for the whole session — not one per
request — and drives the ASGI lifespan protocol around it, so startup
hooks have run before the first request the way they have in production::
with NativeClient(app) as client:
resp = client.get('/hello')
assert resp.status == 200
Prefer the module-level coroutines from an async def test: they call
the app on the test's own loop with no thread hand-off at all.
request(conn, *, body=b'')
¶
Full-control form — see :func:request.
NativeTestResponse
dataclass
¶
What Tier 1 collected from the app's send channel.
body is the concatenation of every http.response.body chunk, so a
streaming handler and a single-shot one are compared the same way.
events keeps the raw ASGI events for tests that assert on the emission
sequence itself (chunk boundaries, more_body flags, trailers).
NativeTestServer
¶
BlackBull's own server on a loopback port, for full-stack tests.
Every layer runs: TCP accept, ConnectionActor, HTTP1Actor parsing,
the native Connection, dispatch, and the bytes the sender puts on the
wire. Anything whose answer depends on the wire — keep-alive reuse, the
HEAD body strip, chunked framing, connection close semantics — is only
observable here.
Async form (preferred: the server shares the test's event loop, as it shares the process loop in production)::
async with NativeTestServer(app) as server:
resp = await server.client.get('/hello')
Synchronous form, for plain def tests — the server runs on one
background loop for the session::
with NativeTestServer(app) as server:
resp = server.client.get('/hello')
The listener binds 127.0.0.1 only, so a test never exposes a port
beyond the machine. Plaintext HTTP/1.1 and WebSocket; TLS and HTTP/2 are
out of scope for this tier — reach for blackbull.fault_injection or the
conformance suites there.
TestClient
¶
In-memory HTTP+WebSocket test client for ASGI 3.0 applications.
Provides a synchronous façade over httpx.AsyncClient +
httpx.ASGITransport by hosting an event loop in a background
thread. HTTP request methods (get, post, put, …)
forward to the underlying httpx.AsyncClient; WebSocket sessions
use a dedicated bridge to the ASGI receive/send channels.
Use as a context manager so that the ASGI lifespan protocol
runs around the test::
with TestClient(app) as client:
...
cookies
property
¶
Persistent cookie jar, forwarded from the underlying httpx.AsyncClient.
Same semantics as httpx.Client.cookies: cookies set by
responses persist across requests on the same client.
headers
property
¶
Default headers applied to every request, forwarded from the
underlying httpx.AsyncClient.
websocket_connect(url, subprotocols=None, headers=None, cookies=None, timeout=5.0)
¶
Open a WebSocket session against the application.
url is a path (relative to the app), e.g. /ws or
/ws?token=abc. Returns a :class:WebSocketTestSession
that should itself be used as a context manager.
WebSocketDisconnect
¶
Bases: Exception
The peer closed the connection.
Raised by :meth:WebSocket.receive and its typed variants. Iterating
with async for handles this for you — the loop simply ends — so catch
it only when you call receive() directly and need the close code.
code is the RFC 6455 close code the peer sent, or 1005 when it sent
none. reason is its optional UTF-8 explanation.
WebSocketTestSession
¶
Synchronous WebSocket session against an ASGI application.
Open via :meth:TestClient.websocket_connect as a context manager::
with client.websocket_connect('/ws') as ws:
ws.send_text('ping')
assert ws.receive_text() == 'pong'
Raises :class:WebSocketDisconnect when the server closes (or
rejects) the connection.
iter_bytes()
¶
Yield successive binary messages from the server until the WebSocket closes.
Mirror of :meth:iter_text for binary frames.
iter_text()
¶
Yield successive text messages from the server until the WebSocket closes.
Stops cleanly when the server emits a websocket.close — the
:class:WebSocketDisconnect raised by the underlying receive
is caught and converted into normal iterator termination, so
the test can write::
with client.websocket_connect('/stream') as ws:
for msg in ws.iter_text():
...
without an explicit try/except around the loop.
build_connection(method, path, *, headers=None, body=b'', http_version='1.1', scheme='http', root_path='', client=_TEST_CLIENT_ADDR, server=_TEST_SERVER_ADDR)
¶
Build the :class:Connection an H/1.1 request line would have produced.
The field derivations mirror :meth:HTTP1Actor._parse so a Tier 1 test and
a real request agree on what the handler sees:
- the query string is split off
pathand carried inquery_string, never inraw_path; pathis percent-decoded,raw_pathkeeps the undecoded bytes;- a
hostheader is supplied when the caller gave none, because every HTTP/1.1 request carries one (RFC 9112 §3.2) and code that reads it would otherwise behave differently under test than on the wire; - a
content-lengthis derived from body for the same reason — an explicit one from the caller wins, so a test can still synthesise a mismatched framing header on purpose.