← Back to DevBytes

Testing Starlette Applications: Unit Tests to Integration

Testing Starlette Applications: Unit Tests to Integration

Starlette is a lightweight ASGI framework that powers popular tools like FastAPI. Because it is small, composable, and built on standard ASGI semantics, it is exceptionally pleasant to test. This tutorial walks you through the full testing journey — from pure unit tests of individual components to full integration tests that exercise your application end-to-end through a real HTTP transport.

Why Testing Starlette Matters

Starlette applications are composed of small, composable pieces: routes, endpoints, middleware, authentication backends, and background tasks. Each of these can be tested in isolation, which gives you a fast feedback loop and confidence that individual pieces behave correctly. Integration tests then verify that those pieces cooperate when wired together.

A good test suite for a Starlette app typically contains three layers:

Project Setup

Install Starlette, an ASGI server, and the testing tools. Starlette's TestClient is built on top of httpx, so you need both packages.

pip install starlette uvicorn httpx pytest pytest-asyncio

Create a minimal application that we will test throughout this tutorial:

# app.py
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route


async def homepage(request):
    name = request.query_params.get("name", "world")
    return JSONResponse({"message": f"Hello, {name}!"})


async def health(request):
    return JSONResponse({"status": "ok"})


async def echo(request):
    payload = await request.json()
    return JSONResponse({"received": payload})


routes = [
    Route("/", homepage),
    Route("/health", health),
    Route("/echo", echo, methods=["POST"]),
]

app = Starlette(routes=routes)

Unit Testing Endpoint Logic

The fastest tests are those that do not touch the network at all. If your endpoint logic is small, you can extract it into a pure function and test it directly. For endpoints that need a Request, you can construct one with minimal arguments.

# tests/test_unit.py
from starlette.requests import Request


def build_request(scope=None, query_string=b""):
    scope = scope or {
        "type": "http",
        "method": "GET",
        "path": "/",
        "query_string": query_string,
        "headers": [],
    }
    return Request(scope)


def test_homepage_with_name():
    from app import homepage

    request = build_request(query_string=b"name=Ada")
    response = homepage(request)

    # Starlette responses are async, so we drive them with a small event loop
    import asyncio
    body = asyncio.get_event_loop().run_until_complete(response.body())
    assert b'"Ada"' in body


def test_homepage_default():
    from app import homepage

    request = build_request()
    response = homepage(request)
    import asyncio
    body = asyncio.get_event_loop().run_until_complete(response.body())
    assert b'"world"' in body

This approach is fast but brittle — you are reaching into Starlette internals. For most teams, using the TestClient is a better default because it handles ASGI plumbing for you.

Using the TestClient

Starlette ships with TestClient, a thin wrapper around httpx that drives your ASGI app in-process. It lets you write tests that look like real HTTP calls without spinning up a server.

# tests/test_client.py
from starlette.testclient import TestClient
from app import app


def test_homepage_default():
    client = TestClient(app)
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"message": "Hello, world!"}


def test_homepage_with_query():
    client = TestClient(app)
    response = client.get("/?name=Ada")
    assert response.status_code == 200
    assert response.json() == {"message": "Hello, Ada!"}


def test_health():
    client = TestClient(app)
    response = client.get("/health")
    assert response.status_code == 200
    assert response.json() == {"status": "ok"}


def test_echo_post():
    client = TestClient(app)
    response = client.post("/echo", json={"foo": "bar"})
    assert response.status_code == 200
    assert response.json() == {"received": {"foo": "bar"}}


def test_echo_rejects_get():
    client = TestClient(app)
    response = client.get("/echo")
    assert response.status_code == 405

Because TestClient runs the ASGI app synchronously, you can use it from plain pytest tests without async fixtures. This is the workhorse of most Starlette test suites.

Testing Middleware

Middleware is where many bugs hide. Starlette middleware can be tested either by mounting it on a small app or by calling the ASGI callable directly. The first approach is simpler and more readable.

# tests/test_middleware.py
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.responses import PlainTextResponse
from starlette.routing import Route
from starlette.testclient import TestClient


async def hello(request):
    return PlainTextResponse("hi")


def make_app():
    middleware = [
        Middleware(
            CORSMiddleware,
            allow_origins=["https://example.com"],
            allow_methods=["GET"],
        )
    ]
    routes = [Route("/", hello)]
    return Starlette(routes=routes, middleware=middleware)


def test_cors_allows_configured_origin():
    client = TestClient(make_app())
    response = client.get(
        "/",
        headers={"Origin": "https://example.com"},
    )
    assert response.headers["access-control-allow-origin"] == "https://example.com"


def test_cors_blocks_other_origin():
    client = TestClient(make_app())
    response = client.get(
        "/",
        headers={"Origin": "https://evil.com"},
    )
    assert "access-control-allow-origin" not in response.headers


def test_cors_preflight():
    client = TestClient(make_app())
    response = client.options(
        "/",
        headers={
            "Origin": "https://example.com",
            "Access-Control-Request-Method": "GET",
        },
    )
    assert response.status_code == 200
    assert "access-control-allow-methods" in response.headers

Building a small throwaway app per test keeps middleware tests focused and avoids coupling them to your main application.

Testing Authentication

Starlette's AuthenticationMiddleware uses an authentication backend that returns an AuthCredentials object and a user. You can test the backend in isolation by constructing a fake connection, then test the full flow with TestClient.

# auth.py
from starlette.authentication import (
    AuthCredentials,
    AuthenticationBackend,
    AuthenticationError,
    SimpleUser,
)
from starlette.requests import HTTPConnection
import base64
import binascii


class BasicAuthBackend(AuthenticationBackend):
    async def authenticate(self, conn):
        if "Authorization" not in conn.headers:
            return None

        auth = conn.headers["Authorization"]
        try:
            scheme, credentials = auth.split()
            if scheme.lower() != "basic":
                return None
            decoded = base64.b64decode(credentials).decode("ascii")
        except (ValueError, UnicodeDecodeError, binascii.Error):
            raise AuthenticationError("Invalid basic auth header")

        username, _, password = decoded.partition(":")
        if username != "admin" or password != "secret":
            raise AuthenticationError("Invalid credentials")

        return AuthCredentials(["authenticated"]), SimpleUser(username)
# tests/test_auth.py
import pytest
from starlette.applications import Starlette
from starlette.authentication import requires
from starlette.middleware import Middleware
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.responses import JSONResponse, PlainTextResponse
from starlette.routing import Route
from starlette.testclient import TestClient

from auth import BasicAuthBackend


@requires("authenticated")
async def protected(request):
    return JSONResponse({"user": request.user.display_name})


def make_app():
    middleware = [Middleware(AuthenticationMiddleware, backend=BasicAuthBackend())]
    routes = [Route("/protected", protected)]
    return Starlette(routes=routes, middleware=middleware)


def test_protected_without_credentials():
    client = TestClient(make_app())
    response = client.get("/protected")
    assert response.status_code == 403


def test_protected_with_valid_credentials():
    client = TestClient(make_app())
    response = client.get(
        "/protected",
        auth=("admin", "secret"),
    )
    assert response.status_code == 200
    assert response.json() == {"user": "admin"}


def test_protected_with_invalid_credentials():
    client = TestClient(make_app())
    with pytest.raises(Exception):
        client.get(
            "/protected",
            auth=("admin", "wrong"),
        )

Testing Async Code Directly

Sometimes you want to call your ASGI app directly without TestClient, especially when testing background tasks or custom ASGI middleware. Use pytest-asyncio and drive the app with a manual scope.

# tests/test_async.py
import pytest
from app import app


@pytest.mark.asyncio
async def test_asgi_directly():
    scope = {
        "type": "http",
        "method": "GET",
        "path": "/",
        "query_string": b"",
        "headers": [],
        "scheme": "http",
        "server": ("testserver", 80),
        "client": ("testclient", 12345),
    }

    received = {}

    async def receive():
        return {"type": "http.request", "body": b"", "more_body": False}

    async def send(message):
        if message["type"] == "http.response.start":
            received["status"] = message["status"]
        elif message["type"] == "http.response.body":
            received["body"] = message["body"]

    await app(scope, receive, send)
    assert received["status"] == 200
    assert b"Hello" in received["body"]

This is verbose, but it gives you full control over the ASGI lifecycle, which is invaluable for testing streaming responses, websockets, and custom middleware that inspects raw messages.

Testing WebSockets

Starlette's TestClient supports WebSocket connections through a context manager. This makes testing real-time endpoints straightforward.

# ws_app.py
from starlette.applications import Starlette
from starlette.routing import WebSocketRoute
from starlette.websockets import WebSocket


async def counter(websocket: WebSocket):
    await websocket.accept()
    for i in range(3):
        await websocket.send_text(f"tick {i}")
    await websocket.close()


app = Starlette(routes=[WebSocketRoute("/ws", counter)])
# tests/test_websocket.py
from starlette.testclient import TestClient
from ws_app import app


def test_websocket_counter():
    client = TestClient(app)
    with client.websocket_connect("/ws") as websocket:
        messages = []
        for _ in range(3):
            messages.append(websocket.receive_text())
        assert messages == ["tick 0", "tick 1", "tick 2"]

Integration Testing with Dependencies

Real applications depend on databases, caches, and external APIs. For integration tests, the goal is to exercise the full stack while keeping external services deterministic. A common pattern is to swap dependencies via Starlette's dependency_overrides-like mechanism or by overriding app state.

# service_app.py
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route


class UserRepository:
    async def find(self, user_id: int):
        # In production this hits a database
        raise NotImplementedError


class ProdUserRepository(UserRepository):
    async def find(self, user_id: int):
        return {"id": user_id, "name": f"User {user_id}"}


class FakeUserRepository(UserRepository):
    def __init__(self):
        self.users = {1: {"id": 1, "name": "Ada"}, 2: {"id": 2, "name": "Grace"}}

    async def find(self, user_id: int):
        return self.users.get(user_id)


async def get_repo(request: Request) -> UserRepository:
    return request.app.state.user_repo


async def get_user(request: Request):
    repo = await get_repo(request)
    user_id = int(request.path_params["user_id"])
    user = await repo.find(user_id)
    if user is None:
        return JSONResponse({"error": "not found"}, status_code=404)
    return JSONResponse(user)


routes = [Route("/users/{user_id}", get_user)]


def create_app(repo: UserRepository):
    app = Starlette(routes=routes)
    app.state.user_repo = repo
    return app


def create_prod_app():
    return create_app(ProdUserRepository())
# tests/test_integration.py
import pytest
from starlette.testclient import TestClient

from service_app import create_app, FakeUserRepository


@pytest.fixture
def client():
    app = create_app(FakeUserRepository())
    return TestClient(app)


def test_get_existing_user(client):
    response = client.get("/users/1")
    assert response.status_code == 200
    assert response.json() == {"id": 1, "name": "Ada"}


def test_get_missing_user(client):
    response = client.get("/users/999")
    assert response.status_code == 404
    assert response.json() == {"error": "not found"}


def test_get_second_user(client):
    response = client.get("/users/2")
    assert response.status_code == 200
    assert response.json()["name"] == "Grace"

By injecting the repository through app.state, your tests can swap in fakes without monkeypatching production code. This pattern scales to databases (use a transaction-rolled-back session), HTTP clients (use respx to mock outbound calls), and message queues (use an in-memory broker).

Testing Background Tasks

Starlette's BackgroundTask runs after the response is sent. With TestClient, background tasks execute synchronously within the request, so you can assert on their side effects immediately.

# bg_app.py
from starlette.applications import Starlette
from starlette.background import BackgroundTask
from starlette.responses import JSONResponse
from starlette.routing import Route


events = []


def log_event(message):
    events.append(message)


async def trigger(request):
    task = BackgroundTask(log_event, "user_signed_up")
    return JSONResponse({"ok": True}, background=task)


app = Starlette(routes=[Route("/trigger", trigger)])
# tests/test_background.py
from starlette.testclient import TestClient
import bg_app


def test_background_task_runs():
    bg_app.events.clear()
    client = TestClient(bg_app.app)
    response = client.get("/trigger")
    assert response.status_code == 200
    assert bg_app.events == ["user_signed_up"]

Testing Error Handling and Exceptions

Starlette lets you register custom exception handlers. Test them by triggering the exception and asserting on the response shape.

# err_app.py
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route


class BusinessError(Exception):
    pass


async def boom(request: Request):
    raise BusinessError("something went wrong")


async def not_found(request: Request):
    return JSONResponse({"error": "missing"}, status_code=404)


routes = [Route("/boom", boom), Route("/missing", not_found)]


async def business_error_handler(request: Request, exc: BusinessError):
    return JSONResponse({"error": exc.args[0]}, status_code=422)


exception_handlers = {BusinessError: business_error_handler}

app = Starlette(routes=routes, exception_handlers=exception_handlers)
# tests/test_errors.py
from starlette.testclient import TestClient
from err_app import app


def test_custom_exception_handler():
    client = TestClient(app, raise_server_exceptions=False)
    response = client.get("/boom")
    assert response.status_code == 422
    assert response.json() == {"error": "something went wrong"}


def test_404_response():
    client = TestClient(app)
    response = client.get("/does-not-exist")
    assert response.status_code == 404

Note the raise_server_exceptions=False argument. By default, TestClient re-raises unhandled server exceptions so you can debug them. When you want to assert on the response produced by your exception handler, disable that behavior.

Best Practices

Structuring the Test Suite

A practical layout separates concerns and makes it easy to run a subset of tests:

myapp/
  app.py
  auth.py
  repositories.py
tests/
  unit/
    test_repositories.py
    test_helpers.py
  components/
    test_middleware.py
    test_auth.py
  integration/
    test_api.py
    test_websocket.py
  conftest.py

In conftest.py, define shared fixtures such as the test client, fake repositories, and sample payloads:

# tests/conftest.py
import pytest
from starlette.testclient import TestClient

from service_app import create_app, FakeUserRepository


@pytest.fixture
def app():
    return create_app(FakeUserRepository())


@pytest.fixture
def client(app):
    return TestClient(app)

Conclusion

Testing Starlette applications is straightforward because the framework is small, composable, and built on a clean ASGI contract. Start with pure unit tests for your business logic, use TestClient to exercise endpoints and middleware, and reserve full integration tests for the critical paths that span your entire stack. By injecting dependencies, isolating middleware, and covering both happy and unhappy paths, you build a suite that runs fast, fails clearly, and gives you the confidence to refactor and ship new features without regressions.

— Ad —

Google AdSense will appear here after approval

← Back to all articles