On this page
AsyncTestClient and TestClient over httpx ASGITransport that drive the ASGI lifespan in-process, with raise_server_exceptions and pytest-safe classes.
#src.fastware.testing
#src.fastware.testing
Sync and async test clients for fastware apps, wrapping httpx with ASGITransport to exercise routes without starting a real network server.
Provides sync and async test clients that wrap httpx with ASGITransport, so tests can exercise a fastware app without starting a real server. Both clients run the ASGI lifespan protocol on enter and shut down on exit.
httpx's ASGITransport does NOT emit lifespan events itself, so each client drives the lifespan protocol explicitly via _LifespanManager: startup on enter, shutdown on exit. This ensures handlers observe the state populated by an app's lifespan context manager, matching real server behaviour.
httpx is a dev/test dependency -- this module should only be imported in test contexts.
#_wrap_raise_server_exceptions
def _wrap_raise_server_exceptions(app: Any, raise_server_exceptions: bool) -> AnyWrap app so every http/websocket scope carries the re-raise flag.
When raise_server_exceptions is True, the app re-raises unhandled handler exceptions out of the ASGI call (httpx's ASGITransport then surfaces them into the test) instead of swallowing them into a 500 response.
#_LifespanManager
Drive the ASGI lifespan protocol around a test client.
httpx's ASGITransport never sends lifespan events, so apps that populate state in a lifespan context manager would otherwise see empty state under test. This manager runs the app's lifespan handler as a background task on the current event loop, feeding it lifespan.startup on :meth:startup and lifespan.shutdown on :meth:shutdown, so the same app object then serves requests with its startup state in place.
A lifespan.startup.failed / lifespan.shutdown.failed message from the app is raised as a hard error -- failures are never swallowed. Apps that do not implement lifespan (the task returns without emitting any lifespan event) are handled transparently.
#startup
async def startup(self) -> None#shutdown
async def shutdown(self) -> None#_wait
async def _wait(self, event: asyncio.Event) -> NoneWait for event, or for the app task to finish first.
An app that does not support lifespan returns without ever setting the event; in that case we stop waiting once the task completes (and re-raise if it errored) so enter/exit never hang.
#AsyncTestClient
Async test client for fastware apps.
Runs the ASGI lifespan protocol (startup on enter, shutdown on exit) so handlers observe the state populated by the app's lifespan handler.
Use as an async context manager::
async with AsyncTestClient(app) as client: resp = await client.get("/health")
By default (raise_server_exceptions=True, matching Starlette's TestClient), an unhandled handler exception re-raises into the test with its real traceback instead of being swallowed into a 500 response. Pass raise_server_exceptions=False to restore the production behaviour where the app serializes the exception into a 500 -- use this in tests that assert on the 500 status code.
#_SyncTestClient
Sync test client for fastware apps.
Runs an AsyncClient on a background event loop so that sync test code can call .get(), .post() etc. without await.
Usage::
with _SyncTestClient(app) as client: resp = client.get("/health") assert resp.status_code == 200
The class is named _SyncTestClient internally and exported as TestClient to avoid pytest treating it as a test class (pytest skips classes whose name starts with _). __test__ = False is set as well so the TestClient alias never triggers a PytestCollectionWarning when imported into a test module.
By default (raise_server_exceptions=True, matching Starlette's TestClient), an unhandled handler exception re-raises into the test with its real traceback instead of being swallowed into a 500 response. Pass raise_server_exceptions=False to restore the production behaviour where the app serializes the exception into a 500 -- use this in tests that assert on the 500 status code.
#_start_loop
def _start_loop(self) -> NoneRun the event loop in a background thread.
#_run
def _run(self, coro: Any) -> AnySchedule a coroutine on the background loop and wait for result.
#get
def get(self, *args: Any, **kwargs: Any) -> Any#post
def post(self, *args: Any, **kwargs: Any) -> Any#put
def put(self, *args: Any, **kwargs: Any) -> Any#patch
def patch(self, *args: Any, **kwargs: Any) -> Any#delete
def delete(self, *args: Any, **kwargs: Any) -> Any#options
def options(self, *args: Any, **kwargs: Any) -> Any#head
def head(self, *args: Any, **kwargs: Any) -> Any#close
def close(self) -> None