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:
- Unit tests — test pure functions, helpers, and individual endpoint logic in isolation.
- Component tests — test middleware, authentication, and routing in partial isolation using Starlette's
TestClientor direct ASGI calls. - Integration tests — test the full application stack, including middleware order, dependencies, and external services (often replaced with fakes or containers).
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
- Prefer
TestClientfor endpoint tests. It handles ASGI plumbing and lets you write tests that read like HTTP interactions. - Keep unit tests pure. Extract business logic out of endpoints into functions that take plain arguments and return plain values. Test those without any HTTP machinery.
- Inject dependencies through
app.stateor factory functions. Avoid global singletons so tests can swap in fakes. - Use fixtures for shared setup. Pytest fixtures keep your tests small and ensure cleanup of databases, temp files, and in-memory stores.
- Test the unhappy paths. Cover 404s, 405s, validation failures, authentication failures, and exception handlers — these are where production incidents usually originate.
- Isolate middleware tests. Build a tiny app with only the middleware under test so failures point clearly at the cause.
- Run a small set of true integration tests. Most of your suite should be fast unit and component tests; reserve slow integration tests for critical user journeys.
- Disable
raise_server_exceptionsonly when needed. Leaving it on by default catches unexpected errors during development. - Mock outbound HTTP with
respx. If your app calls external APIs, mock them at thehttpxtransport level rather than patching functions.
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.