blackbull.testing.native¶
blackbull.testing.native
¶
Native-path test client — the two tiers that drive BlackBull's own request path rather than the ASGI compatibility boundary.
BlackBull threads a typed :class:~blackbull.connection.Connection end to
end; the ASGI scope dict survives only at two boundaries. The
compatibility client in :mod:blackbull.testing reaches the app through
httpx.ASGITransport → scope dict → Connection.from_scope(), so the
isinstance(conn, Connection) branch of BlackBull.__call__ — the branch
every production request takes — is never exercised by it. A defect can
therefore live on the native path while the whole compat-driven suite passes.
Two tiers close that, mirroring what every framework that owns its protocol stack provides:
Tier 1 — :func:request and the verb helpers build a Connection and
call app(conn, receive, send) directly. No socket, no protocol actor:
everything from Connection inward (dispatcher, middleware chain, router,
handlers, DI, events, response serialisation). The equivalent of actix-web's
init_service or Fastify's .inject()::
resp = await native.get(app, '/hello')
assert resp.status == 200
Tier 2 — :class:NativeTestServer binds a real loopback socket and runs
BlackBull's own :class:~blackbull.server.server.Server, so a request
travels accept → HTTP1Actor parse → Connection → native dispatch →
wire bytes. The equivalent of aiohttp's TestServer or Go's
httptest.NewServer::
async with NativeTestServer(app) as server:
resp = await server.client.get('/hello')
Both tiers are async-first because the app entry point is a coroutine and a
handler runs on the caller's event loop — which is also what production does.
:class:NativeClient and the synchronous form of :class:NativeTestServer
wrap them for tests written as plain def, each owning one background
event loop for its whole lifetime rather than per request.
Which instrument to reach for is documented in docs/guide/testing.md:
Tier 1 for application logic, Tier 2 for anything whose answer depends on the
wire, and :class:~blackbull.testing.TestClient for the ASGI boundary itself.
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.
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.
delete(app, path, **kwargs)
async
¶
Drive app with a DELETE through the native dispatch path.
get(app, path, **kwargs)
async
¶
Drive app with a GET through the native dispatch path.
head(app, path, **kwargs)
async
¶
Drive app with a HEAD through the native dispatch path.
The handler sees HEAD: rewriting it to GET and stripping the body
is the H/1.1 actor's job (RFC 9110 §9.3.2), which is below this tier. Use
:class:NativeTestServer to assert HEAD's wire behaviour.
options(app, path, **kwargs)
async
¶
Drive app with an OPTIONS through the native dispatch path.
patch(app, path, **kwargs)
async
¶
Drive app with a PATCH through the native dispatch path.
post(app, path, **kwargs)
async
¶
Drive app with a POST through the native dispatch path.
put(app, path, **kwargs)
async
¶
Drive app with a PUT through the native dispatch path.
request(app, conn, *, body=b'')
async
¶
Call app(conn, receive, send) and collect the response.
The full-control form: build (or mutate) a :class:Connection yourself and
drive the app with it. The verb helpers below are thin wrappers over this.
The receive channel delivers body as one http.request event and then
reports http.disconnect — the same terminal signal a real recipient
gives once the body is drained, so a handler that over-reads gets the
production answer rather than hanging.