Skip to content

Sync/async ASGI test clients, transport, WebSocket testing, helpers

Internal engineering reference for Sillo’s test client.

Source: core/sillo/testclient/ (9 files, ~2,063 lines)


The test client provides sync and async HTTP clients for testing ASGI applications without a real server. It subclasses httpx.Client and httpx.AsyncClient, replacing the network transport with an in-process ASGI adapter.

graph TD
    subgraph "Test Code"
        A["TestClient / AsyncTestClient"]
    end

    subgraph "httpx Layer"
        B["httpx.Client"]
        C["httpx.AsyncClient"]
    end

    subgraph "Transport Layer"
        D["TestClientTransport<br/>(sync, uses BlockingPortal)"]
        E["AsyncTestClientTransport<br/>(async-native)"]
    end

    subgraph "ASGI App"
        F["SilloApp"]
    end

    A --> B
    A --> C
    B --> D
    C --> E
    D -->|"BlockingPortal"| F
    E -->|"await app()"| F
sequenceDiagram
    participant Test as Test Code
    participant Client as TestClient
    participant Transport as TestClientTransport
    participant Portal as BlockingPortal
    participant App as ASGI App

    Test->>Client: client.get("/api/users")
    Client->>Transport: handle_request(httpx.Request)
    Transport->>Transport: Build ASGI scope
    Transport->>Portal: portal.call(app, scope, receive, send)
    Portal->>App: await app(scope, receive, send)
    App->>App: Process request
    App-->>Transport: send(http.response.start)
    App-->>Transport: send(http.response.body)
    Transport-->>Client: httpx.Response
    Client-->>Test: Response object
FilePathLinesPurpose
__init__.pycore/sillo/testclient/__init__.py17Public API re-exports
base.pycore/sillo/testclient/base.py523TestClient (sync)
async_client.pycore/sillo/testclient/async_client.py273AsyncTestClient
helpers.pycore/sillo/testclient/helpers.py149create_client, create_async_client
exceptions.pycore/sillo/testclient/exceptions.py17UpgradeException, ASGISpecViolation
_internal/transport.pycore/sillo/testclient/_internal/transport.py697Transport implementations
_internal/websockets.pycore/sillo/testclient/_internal/websockets.py295WebSocket support
_internal/utils.pycore/sillo/testclient/_internal/utils.py37ASGI utilities
_internal/inputs.pycore/sillo/testclient/_internal/inputs.py47Request input defaults
_internal/types.pycore/sillo/testclient/_internal/types.py15Type aliases

File: core/sillo/testclient/base.py, line 49

class TestClient(httpx.Client):
__test__ = False # Prevent pytest collection
def __init__(
self,
app: ASGIApp,
base_url: str = "http://testserver",
raise_server_exceptions: bool = True,
root_path: str = "",
backend: Literal["asyncio", "trio"] = "asyncio",
backend_options: dict[str, Any] | None = None,
cookies: CookieTypes | None = None,
headers: HeaderTypes | None = None,
follow_redirects: bool = True,
check_asgi_conformance: bool = True,
):

Initialisation steps:

  1. Creates AsyncBackend dict with backend and backend_options.
  2. Wraps ASGI2 apps via WrapASGI2 if needed.
  3. Creates TestClientTransport with the app and portal factory.
  4. Sets default user-agent: testclient header.
  5. Calls super().__init__() with the transport.
# core/sillo/testclient/base.py, line 129
def request(self, method, url, *, content, data, files, json, params,
headers, cookies, auth, follow_redirects, timeout, extensions,
stream) -> httpx.Response:

Normalises data when it’s a list of (key, value) pairs into URL-encoded form content. Handles stream=True by entering a streaming context.

All delegate to _process_request:

MethodLine
get(url, **kwargs)265
head(url, **kwargs)273
post(url, **kwargs)281
put(url, **kwargs)289
patch(url, **kwargs)297
delete(url, **kwargs)305
options(url, **kwargs)313
# core/sillo/testclient/base.py, line 321
def websocket_connect(self, url, subprotocols=None, **kwargs) -> WebSocketTestSession:
  1. Prepares WebSocket headers (connection: upgrade, sec-websocket-key, etc.).
  2. Issues a GET request with upgrade headers.
  3. Catches UpgradeException raised by the transport.
  4. Returns the WebSocketTestSession from the exception.
# core/sillo/testclient/base.py, line 374
def __enter__(self) -> Self:
  1. Starts a BlockingPortal via anyio.from_thread.start_blocking_portal.
  2. Creates two anyio memory object streams (stream_send, stream_receive).
  3. Starts the lifespan task.
  4. Calls wait_startup().
# core/sillo/testclient/base.py, line 411
async def __aenter__(self) -> Self:
  1. Creates an anyio.create_task_group().
  2. Creates memory object streams.
  3. Starts _lifespan_runner in the task group.
  4. Calls wait_startup().

File: core/sillo/testclient/async_client.py, line 38

class AsyncTestClient(httpx.AsyncClient):
__test__ = False

Mirrors TestClient but is async-native. Key differences:

AspectTestClientAsyncTestClient
Base classhttpx.Clienthttpx.AsyncClient
TransportTestClientTransportAsyncTestClientTransport
PortalBlockingPortalNone (direct await)
Context manager__enter__/__aenter____aenter__ only
HTTP verbsSyncAsync

Same parameters as TestClient.__init__. Creates AsyncTestClientTransport instead of TestClientTransport.

# core/sillo/testclient/async_client.py, line 201
async def __aenter__(self) -> Self:
self._tg = anyio.create_task_group()
await self._tg.__aenter__()
# ... create streams, start lifespan, wait_startup

Both TestClient and AsyncTestClient manage the ASGI lifespan protocol.

sequenceDiagram
    participant Client
    participant App as ASGI App

    Client->>App: {"type": "lifespan.startup"}
    App-->>Client: {"type": "lifespan.startup.complete"}
    Note over Client,App: App is running
    Client->>App: {"type": "lifespan.shutdown"}
    App-->>Client: {"type": "lifespan.shutdown.complete"}
stream_send, stream_receive = anyio.create_memory_object_stream()
  • stream_send: Client → App (lifespan events).
  • stream_receive: App → Client (lifespan responses).
# core/sillo/testclient/base.py, line 470
def wait_startup(self):
self.stream_send.send_nowait({"type": "lifespan.startup"})
message = self.stream_receive.receive_nowait()
if message is None:
raise RuntimeError("Lifespan startup failed: app did not respond")
if message["type"] == "lifespan.startup.failed":
raise RuntimeError(f"Lifespan startup failed: {message.get('message', '')}")
# core/sillo/testclient/base.py, line 491
def wait_shutdown(self):
self.stream_send.send_nowait({"type": "lifespan.shutdown"})
# ... wait for "lifespan.shutdown.complete" or "lifespan.shutdown.failed"
flowchart TD
    A["TestClient.__enter__"] --> B["Start BlockingPortal"]
    B --> C["Create memory streams"]
    C --> D["Start lifespan task via portal"]
    D --> E["wait_startup()"]

    F["AsyncTestClient.__aenter__"] --> G["Create task group"]
    G --> H["Create memory streams"]
    H --> I["Start lifespan task in group"]
    I --> J["wait_startup()"]

File: core/sillo/testclient/_internal/transport.py, line 18

class TestClientTransport(httpx.BaseTransport):
encoding: str = "ascii"

Synchronous HTTP transport that bridges httpx to the ASGI app via a blocking portal.

# core/sillo/testclient/_internal/transport.py, line 51
def handle_request(self, request) -> httpx.Response:
  1. Parses URL (scheme, netloc, path, raw_path, query).
  2. Extracts host, port, default_port.
  3. Builds ASGI-compatible header list as bytes tuples.
  4. If scheme is ws/wss: delegates to _handle_websocket_request and raises UpgradeException.
  5. Otherwise: builds HTTP scope and processes the request.
# core/sillo/testclient/_internal/transport.py, line 245
def _process_http_request(self, scope, request) -> httpx.Response:

Defines inner receive() and send() async functions implementing the ASGI protocol:

Body TypeHandling
strRaises ASGISpecViolation if conformance check enabled
GeneratorSends chunks, then more_body=False
NoneSends {} with more_body=False
bytesSends {body: bytes, more_body: False}
Message TypeHandling
http.response.startStores status, headers. Validates conformance.
http.response.bodyAccumulates body chunks.
http.response.debugStores template/context for debug.
  • On exception: re-raises if raise_server_exceptions; otherwise returns 500.
  • If no response started: raises ASGISpecViolation if conformance check enabled, else returns 500.

File: core/sillo/testclient/_internal/transport.py, line 455

class AsyncTestClientTransport(httpx.AsyncBaseTransport):
encoding: str = "ascii"

Same architecture as TestClientTransport but async-native, no portal needed. Directly awaits the ASGI app.

AspectSyncAsync
App invocationVia BlockingPortalDirect await app(scope, receive, send)
Response completeManual eventfinally: response_complete.set()
500 body (no response)Emptyb"Internal Server Error"

File: core/sillo/testclient/_internal/websockets.py

# core/sillo/testclient/_internal/exceptions.py, line 9
class UpgradeException(Exception):
def __init__(self, session: WebSocketTestSession):
self.session = session

Raised by the transport when a WebSocket request is detected. The client’s websocket_connect catches this and returns the session.

# core/sillo/testclient/_internal/websockets.py, line 42
class WebSocketTestSession:
def __init__(self, app, scope, portal_factory):

Internal queues:

QueueDirectionType
_receive_queueTest → Appqueue.Queue[Message]
_send_queueApp → Testqueue.Queue[Message | BaseException]
def __enter__(self) -> Self:
# 1. Enter portal factory
# 2. Start _run task
# 3. Send websocket.connect
# 4. Receive response
# 5. Check for denial (websocket.http.response.start)
# 6. Store accepted_subprotocol and extra_headers
def __exit__(self, *args):
# 1. Send close (code 1000)
# 2. Notify close (set should_close event)
# 3. Close exit stack
# 4. Drain _send_queue, re-raise exceptions
MethodPayload
send(message)Raw message dict
send_text(data){"type": "websocket.receive", "text": data}
send_bytes(data){"type": "websocket.receive", "bytes": data}
send_json(data, mode)JSON-serialized as text or bytes
close(code, reason){"type": "websocket.disconnect", ...}
MethodReturns
receive()Raw message dict
receive_text()message["text"]
receive_bytes()message["bytes"]
receive_json(mode)Deserialized JSON
class WebSocketDenialResponse(httpx.Response, WebSocketDisconnect):
"""Raised when WebSocket is closed before being accepted."""

Multiple inheritance from both httpx.Response and WebSocketDisconnect. Carries both the HTTP response data and disconnect semantics.


File: core/sillo/testclient/helpers.py

def create_client(
title="Test",
version="0.1.0",
description="",
server_error_handler=None,
lifespan=None,
routes=(),
dependencies=None,
client_config=None,
) -> TestClient:

Builds a SilloApp with the given parameters, applies default client config (with optional overrides), and returns a TestClient wrapping that app.

Same parameters but returns an AsyncTestClient.

from sillo.testclient import create_client
# Quick setup for route testing
client = create_client(routes=[user_routes, auth_routes])
with client:
resp = client.get("/api/users")
assert resp.status_code == 200

Both transports optionally validate ASGI spec compliance.

CheckMessage TypeCondition
Body is byteshttp.response.bodybody must be bytes, not str
Headers are bytes tupleshttp.response.startEach header must be (bytes, bytes)
No newlines in headershttp.response.start\n not allowed in header names/values
Response startedEnd of requestAt least one http.response.start sent
String bodyreceive()str body raises violation
class ASGISpecViolation(Exception):
"""Raised when the ASGI app violates the ASGI specification."""
client = TestClient(app, check_asgi_conformance=False)

Useful for testing apps that intentionally bend the spec.


from sillo.testclient import TestClient
def test_home_page():
with TestClient(app) as client:
resp = client.get("/")
assert resp.status_code == 200
assert "Welcome" in resp.text
import pytest
from sillo.testclient import AsyncTestClient
@pytest.mark.asyncio
async def test_home_page():
async with AsyncTestClient(app) as client:
resp = await client.get("/")
assert resp.status_code == 200
def test_create_user():
with TestClient(app) as client:
resp = client.post("/api/users", json={
"name": "Alice",
"email": "alice@example.com",
})
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "Alice"
def test_login():
with TestClient(app) as client:
resp = client.post("/login", data={
"username": "admin",
"password": "secret",
})
assert resp.status_code == 302
def test_upload():
with TestClient(app) as client:
resp = client.post("/upload", files={
"file": ("test.txt", b"hello world", "text/plain"),
})
assert resp.status_code == 200
def test_session():
with TestClient(app) as client:
# Login (sets session cookie)
client.post("/login", json={"username": "admin", "password": "secret"})
# Subsequent requests include the cookie
resp = client.get("/dashboard")
assert resp.status_code == 200
def test_websocket():
with TestClient(app) as client:
with client.websocket_connect("/ws") as ws:
ws.send_text("Hello")
data = ws.receive_text()
assert data == "Hello from server"
def test_server_error():
with TestClient(app, raise_server_exceptions=False) as client:
resp = client.get("/crash")
assert resp.status_code == 500
def test_auth_header():
with TestClient(app, headers={"Authorization": "Bearer token123"}) as client:
resp = client.get("/api/me")
assert resp.status_code == 200
def test_startup_shutdown():
events = []
app.on_startup(lambda: events.append("startup"))
app.on_shutdown(lambda: events.append("shutdown"))
with TestClient(app) as client:
assert events == ["startup"]
assert events == ["startup", "shutdown"]