blackbull.testing¶
blackbull.testing
¶
Application test instruments.
Native helpers test dispatch; NativeTestServer tests the wire stack; TestClient tests the external ASGI boundary. Choosing one does not cover the other layers. TestClient runs lifespan and raises reported startup or shutdown failures; see 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.
__enter__()
¶
Start the background loop and run the application's lifespan startup.
__init__(app)
¶
Prepare a client for app; the loop thread starts on __enter__.
delete(path, **kwargs)
¶
DELETE path on the app's own dispatch, no socket involved.
get(path, **kwargs)
¶
GET path on the app's own dispatch, no socket involved.
head(path, **kwargs)
¶
HEAD path on the app's own dispatch, no socket involved.
options(path, **kwargs)
¶
OPTIONS path on the app's own dispatch, no socket involved.
patch(path, **kwargs)
¶
PATCH path on the app's own dispatch, no socket involved.
post(path, **kwargs)
¶
POST path on the app's own dispatch, no socket involved.
put(path, **kwargs)
¶
PUT path on the app's own dispatch, no socket involved.
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
¶
Synchronous HTTP/ASGITransport and native BlackBull WebSocket tests.
HTTP methods forward to httpx. WebSocket sessions pass a Connection and do not exercise a generic scope-only ASGI app. Use as a context manager to run lifespan startup and shutdown.
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.
__enter__()
¶
Start the background loop and run the application's lifespan startup.
__init__(app, base_url='http://testserver', raise_app_exceptions=True, root_path='', cookies=None, headers=None, follow_redirects=False)
¶
Prepare a client; the loop thread and lifespan start on __enter__.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
app
|
Any
|
The ASGI application under test. |
required |
base_url
|
str
|
Prefix for relative request URLs. |
'http://testserver'
|
raise_app_exceptions
|
bool
|
Re-raise an exception from the application instead of turning it into a 500 response. |
True
|
root_path
|
str
|
ASGI |
''
|
cookies
|
Any | None
|
Initial cookie jar. |
None
|
headers
|
Any | None
|
Default headers for every request. |
None
|
follow_redirects
|
bool
|
Follow 3xx responses automatically. |
False
|
request(method, url, **kwargs)
¶
Send method to url and return the response.
Keyword arguments go to httpx.AsyncClient.request. Raises
RuntimeError if the client is used outside its with block,
since the loop thread and lifespan are what that block owns.
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 WebSocketTestSession
that should itself be used as a context manager.
WebSocketDisconnect
¶
Bases: Exception
The peer closed the connection.
Raised by 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 BlackBull WebSocket session using its native Connection boundary.
Open through TestClient.websocket_connect as a context manager. Server close or handshake rejection raises WebSocketDisconnect.
__init__(app, path, subprotocols=None, headers=None, cookies=None, timeout=5.0)
¶
Prepare a session; the handshake runs on __enter__.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
app
|
Any
|
The ASGI application to connect to. |
required |
path
|
str
|
Request path, including any query string. |
required |
subprotocols
|
list[str] | None
|
Offered in |
None
|
headers
|
list[tuple[str, str]] | None
|
Extra request headers, as |
None
|
cookies
|
dict[str, str] | None
|
Sent as a single |
None
|
timeout
|
float
|
Seconds any one receive will wait before giving up. |
5.0
|
close(code=1000)
¶
Disconnect with code and tear the session down.
Idempotent, and safe on a session whose handshake never completed.
__exit__ calls it, so a with block needs no explicit close.
iter_bytes()
¶
Yield successive binary messages from the server until the WebSocket closes.
Mirror of iter_text for binary frames.
iter_text()
¶
Yield text messages until close; convert WebSocketDisconnect to iterator termination.
receive_bytes()
¶
The next binary message.
Raises WebSocketDisconnect if the server closed instead, and
RuntimeError if what arrived was a text message.
receive_json()
¶
The next text message, parsed as JSON.
receive_text()
¶
The next text message.
Raises WebSocketDisconnect if the server closed instead, and
RuntimeError if what arrived was a binary message.
send_bytes(data)
¶
Send data to the application as a binary message.
send_json(data)
¶
Serialise data as JSON and send it as a text message.
send_text(text)
¶
Send text to the application as a text message.
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 a Connection without protocol parsing.
Split query from path; retain undecoded raw_path and percent-decode path. Supply missing Host and Content-Length; an explicit Content-Length wins so tests can synthesize inconsistent framing.