fastware v0.6.0 /Testing API Reference
On this page

Reference for fastware's in-process test clients: AsyncTestClient and TestClient over httpx ASGITransport with ASGI lifespan support.

#Testing API Reference

The testing module provides 2 test clients (sync and async) that wrap httpx with ASGITransport, so tests can exercise a fastware app without starting a real server. Both clients run requests against the ASGI application directly in-process.

httpx is a dev/test dependency -- this module should only be imported in test contexts.

#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

python
def _wrap_raise_server_exceptions(app: Any, raise_server_exceptions: bool) -> Any

Wrap 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

python
async def startup(self) -> None

#shutdown

python
async def shutdown(self) -> None

#_wait

python
async def _wait(self, event: asyncio.Event) -> None

Wait 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

python
def _start_loop(self) -> None

Run the event loop in a background thread.

#_run

python
def _run(self, coro: Any) -> Any

Schedule a coroutine on the background loop and wait for result.

#get

python
def get(self, *args: Any, **kwargs: Any) -> Any

#post

python
def post(self, *args: Any, **kwargs: Any) -> Any

#put

python
def put(self, *args: Any, **kwargs: Any) -> Any

#patch

python
def patch(self, *args: Any, **kwargs: Any) -> Any

#delete

python
def delete(self, *args: Any, **kwargs: Any) -> Any

#options

python
def options(self, *args: Any, **kwargs: Any) -> Any
python
def head(self, *args: Any, **kwargs: Any) -> Any

#close

python
def close(self) -> None

#AsyncTestClient (async)

Use AsyncTestClient in pytest async tests for full async/await support. It wraps an httpx.AsyncClient with ASGITransport, running requests directly against the ASGI application in-process without any network overhead or server startup delay:

python
import pytest
from fastware import Router, create_app
from fastware.testing import AsyncTestClient

router = Router()

@router.get("/health")
async def health(request):
    return {"status": "ok"}

app = create_app(router)

@pytest.mark.asyncio
async def test_health():
    async with AsyncTestClient(app) as client:
        resp = await client.get("/health")
        assert resp.status_code == 200
        assert resp.json() == {"status": "ok"}

The async with block creates and closes the underlying httpx.AsyncClient. Inside the block, client is a full httpx.AsyncClient with all its methods available (get, post, put, patch, delete, options, head).

#TestClient (sync)

Use TestClient for synchronous test code when you prefer not to use async/await in your test functions. It runs an event loop in a background thread, so you can call .get(), .post(), etc. without await, while still exercising the full ASGI application stack including middleware and lifespan:

python
from fastware import Router, create_app
from fastware.testing import TestClient

router = Router()

@router.get("/health")
async def health(request):
    return {"status": "ok"}

app = create_app(router)

def test_health_sync():
    with TestClient(app) as client:
        resp = client.get("/health")
        assert resp.status_code == 200
        assert resp.json() == {"status": "ok"}

TestClient supports get, post, put, patch, delete, options, and head. It can also be used without a context manager by calling client.close() manually.

#Example From the Test Suite

This example from the fastware test suite demonstrates the AsyncTestClient in action, testing route matching with path parameter extraction and type coercion, HTTP method dispatch across GET and POST handlers, correct 404 responses for unmatched paths, and proper error handling -- all exercised against a real ASGI application without starting a network server:

python
class TestRouterMatching:
    def test_exact_path_match(self):
        r = Router()
        r.add_route("GET", "/api/health", lambda req: None)
        match = r.match("GET", "/api/health")
        assert match is not None
        handler, params = match
        assert params == {}

    def test_parameterized_path(self):
        r = Router()
        r.add_route("GET", "/api/users/{id}", lambda req: None)
        match = r.match("GET", "/api/users/42")
        assert match is not None
        _, params = match
        assert params == {"id": "42"}

    def test_multiple_params(self):
        r = Router()
        r.add_route("GET", "/api/{org}/repos/{repo}", lambda req: None)
        match = r.match("GET", "/api/acme/repos/widgets")
        assert match is not None
        _, params = match
        assert params == {"org": "acme", "repo": "widgets"}

    def test_method_filtering(self):
        """POST handler does not match GET request."""
        r = Router()
        r.add_route("POST", "/api/items", lambda req: None)
        assert r.match("GET", "/api/items") is None

    def test_no_match_returns_none(self):
        r = Router()
        r.add_route("GET", "/api/health", lambda req: None)
        assert r.match("GET", "/api/other") is None

    def test_no_match_different_segment_count(self):
        r = Router()
        r.add_route("GET", "/api/health", lambda req: None)
        assert r.match("GET", "/api/health/extra") is None

    def test_first_match_wins(self):
        r = Router()
        first = lambda req: "first"
        second = lambda req: "second"
        r.add_route("GET", "/api/{id}", first)
        r.add_route("GET", "/api/{name}", second)
        handler, _ = r.match("GET", "/api/42")
        assert handler is first

    def test_exact_beats_nothing_when_registered_first(self):
        """When an exact route is registered before a parameterized one,
        the exact route matches first."""
        r = Router()
        exact = lambda req: "exact"
        param = lambda req: "param"
        r.add_route("GET", "/api/health", exact)
        r.add_route("GET", "/api/{id}", param)
        handler, params = r.match("GET", "/api/health")
        assert handler is exact
        assert params == {}

#pytest Integration

A typical conftest.py for a fastware project sets up reusable app and client fixtures that other test modules can inject, reducing boilerplate and ensuring consistent application configuration across all test files:

python
import pytest
from fastware import Router, create_app
from fastware.testing import AsyncTestClient


def make_app():
    """Create the application for testing."""
    router = Router()

    @router.get("/health")
    async def health(request):
        return {"status": "ok"}

    @router.get("/items/{id:int}")
    async def get_item(request):
        item_id = request.path_params["id"]
        return {"id": item_id}

    return create_app(router)


@pytest.fixture
def app():
    return make_app()


@pytest.fixture
async def client(app):
    async with AsyncTestClient(app) as c:
        yield c

Then in test files:

python
import pytest

@pytest.mark.asyncio
async def test_get_item(client):
    resp = await client.get("/items/42")
    assert resp.status_code == 200
    assert resp.json()["id"] == 42

@pytest.mark.asyncio
async def test_not_found(client):
    resp = await client.get("/nonexistent")
    assert resp.status_code == 404
Search