The Trick to Testing Starlette Apps
Testing web apps usually means spinning up a server, making real HTTP requests, and dealing with all sorts of flakiness. Port conflicts. Slow startup times. Tests that pass locally but fail in CI because of timing issues. It's a mess.
But there's a better way. And once you learn it, you'll never go back.
The magic: ASGITransport
httpx can talk directly to your ASGI app without a running server. The trick is using ASGITransport:
from httpx import AsyncClient, ASGITransport
from app.server import app
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url='http://test') as c:
yield c
async def test_homepage(client):
response = await client.get('/')
assert response.status_code == 200
assert 'Welcome' in response.text
That's it. No server running. No ports to manage. No waiting for startup. Just fast, reliable tests that exercise your entire application stack.
How this works under the hood
ASGI (Asynchronous Server Gateway Interface) is a standard interface between Python web frameworks and servers. Your Starlette app speaks ASGI. Normally, a server like uvicorn receives HTTP requests and translates them into ASGI calls to your app.
ASGITransport cuts out the middleman. It takes httpx requests and translates them directly into ASGI calls, without any actual network traffic. Your app thinks it's handling real requests, but everything happens in-process.
Normal request flow:
Browser -> Network -> Uvicorn -> ASGI -> Your App
Test request flow:
Test -> ASGITransport -> ASGI -> Your App
The network layer is completely bypassed. That's why tests are so fast.
Why this is awesome
1. Tests are fast. No network overhead. No server startup time. A test that might take 500ms with a real server takes 5ms with ASGITransport.
2. Tests are reliable. No 'address already in use' errors. No race conditions between starting the server and making requests. No flaky tests that fail randomly in CI.
3. You can debug. Set breakpoints anywhere in your app code. The debugger works normally because everything runs in the same process. No remote debugging shenanigans.
4. Full integration testing. You're testing the actual app, not a mock. Middleware runs. Template rendering happens. Database queries execute. Everything works just like in production.
The pytest-asyncio setup
Since Starlette is async, you need pytest-asyncio. Add this to your pyproject.toml:
[tool.pytest.ini_options]
asyncio_mode = 'auto'
asyncio_default_fixture_loop_scope = 'function'
Now your tests can be async def without any extra decorators:
async def test_blog_list(client):
response = await client.get('/blog')
assert response.status_code == 200
async def test_blog_post(client):
response = await client.get('/blog/my-first-post')
assert response.status_code == 200
No @pytest.mark.asyncio needed on every test.
A complete test file example
Here's what a real test file looks like in my projects:
import pytest
from httpx import AsyncClient, ASGITransport
from app.server import app
@pytest.fixture
async def client():
'''Create a test client that talks directly to the ASGI app.'''
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport,
base_url='http://test'
) as client:
yield client
class TestHomepage:
async def test_returns_200(self, client):
response = await client.get('/')
assert response.status_code == 200
async def test_contains_title(self, client):
response = await client.get('/')
assert '<title>' in response.text
class TestBlog:
async def test_list_posts(self, client):
response = await client.get('/blog')
assert response.status_code == 200
assert 'blog' in response.text.lower()
async def test_single_post(self, client):
response = await client.get('/blog/welcome-to-the-beniverse')
assert response.status_code == 200
async def test_missing_post_returns_404(self, client):
response = await client.get('/blog/nonexistent-post')
assert response.status_code == 404
class TestAPI:
async def test_projects_json(self, client):
response = await client.get('/api/projects')
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
async def test_posts_json(self, client):
response = await client.get('/api/posts')
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
Testing with different app states
Sometimes you need to test with different configurations or data. Create specialized fixtures:
@pytest.fixture
async def client_with_empty_db():
'''Client with an empty database for testing edge cases.'''
# Clear the database or use a test database
await clear_test_data()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url='http://test') as c:
yield c
# Restore data after test
await seed_test_data()
async def test_empty_blog_shows_message(client_with_empty_db):
response = await client_with_empty_db.get('/blog')
assert 'No posts yet' in response.text
Testing POST requests and forms
httpx makes it easy to test forms and API endpoints:
async def test_contact_form_submission(client):
response = await client.post(
'/contact',
data={
'name': 'Test User',
'email': 'test@example.com',
'message': 'Hello!'
}
)
assert response.status_code == 200
async def test_api_create_item(client):
response = await client.post(
'/api/items',
json={'name': 'New Item', 'price': 19.99}
)
assert response.status_code == 201
data = response.json()
assert data['name'] == 'New Item'
The data parameter sends form-encoded data (like a traditional HTML form), while json sends JSON payloads. httpx handles the Content-Type headers automatically.
You can also test file uploads:
async def test_file_upload(client):
files = {'upload': ('test.txt', b'file contents', 'text/plain')}
response = await client.post('/upload', files=files)
assert response.status_code == 200
assert 'File uploaded' in response.text
And multipart forms with both data and files:
async def test_form_with_file(client):
files = {'avatar': ('profile.jpg', b'fake image data', 'image/jpeg')}
data = {'username': 'testuser', 'bio': 'Test bio'}
response = await client.post('/profile', data=data, files=files)
assert response.status_code == 200
Testing with authentication
If your app has authentication, you can set headers or cookies:
@pytest.fixture
async def authenticated_client():
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport,
base_url='http://test',
headers={'Authorization': 'Bearer test-token'}
) as client:
yield client
async def test_admin_page_requires_auth(client):
response = await client.get('/admin')
assert response.status_code == 401
async def test_admin_page_with_auth(authenticated_client):
response = await authenticated_client.get('/admin')
assert response.status_code == 200
For session-based auth with cookies, you can test the full login flow:
async def test_login_flow(client):
# First, try to access a protected page
response = await client.get('/dashboard')
assert response.status_code == 302 # Redirect to login
# Log in
response = await client.post(
'/login',
data={'username': 'testuser', 'password': 'testpass'}
)
assert response.status_code == 200
# Now the protected page should work
# httpx automatically handles cookies
response = await client.get('/dashboard')
assert response.status_code == 200
assert 'Welcome' in response.text
The client automatically maintains cookies between requests, just like a real browser. This makes testing session-based auth straightforward.
You can also manually set cookies for specific test scenarios:
async def test_with_specific_cookie(client):
client.cookies.set('session_id', 'test-session-123')
response = await client.get('/profile')
assert response.status_code == 200
Performance testing
Since tests are fast, you can run hundreds of them. I aim for comprehensive coverage:
@pytest.mark.parametrize('path', [
'/',
'/about',
'/blog',
'/projects',
'/resume',
])
async def test_all_pages_return_200(client, path):
response = await client.get(path)
assert response.status_code == 200
This single parametrized test checks all your main routes.
The conftest.py pattern
Put your fixtures in conftest.py so they're available to all tests:
# tests/conftest.py
import pytest
from httpx import AsyncClient, ASGITransport
from app.server import app
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url='http://test') as c:
yield c
Now every test file can just use client without importing anything.
Testing middleware and error handlers
One of the best parts about this approach is that you're testing your entire application stack, including middleware and error handlers. Everything runs exactly like in production.
async def test_cors_headers(client):
response = await client.get('/')
assert 'Access-Control-Allow-Origin' in response.headers
async def test_404_page(client):
response = await client.get('/this-does-not-exist')
assert response.status_code == 404
assert 'Not Found' in response.text
async def test_500_error_handler(client):
# Assuming you have an endpoint that triggers an error
response = await client.get('/trigger-error')
assert response.status_code == 500
# Make sure your custom error page renders
assert 'Something went wrong' in response.text
You can also test that middleware processes requests in the right order:
async def test_request_id_middleware(client):
response = await client.get('/')
assert 'X-Request-ID' in response.headers
async def test_timing_headers(client):
response = await client.get('/')
assert 'X-Response-Time' in response.headers
Testing redirects and following them
httpx gives you control over redirect behavior:
async def test_redirect_response(client):
# Don't follow redirects
response = await client.get('/old-url', follow_redirects=False)
assert response.status_code == 301
assert response.headers['Location'] == '/new-url'
async def test_redirect_destination(client):
# Follow redirects (this is the default)
response = await client.get('/old-url', follow_redirects=True)
assert response.status_code == 200
assert response.url.path == '/new-url'
This is useful for testing that your redirects are set up correctly, especially for SEO purposes.
Common gotchas and how to fix them
Gotcha 1: Database state between tests
If you're using a database, tests can interfere with each other. Use fixtures to manage state:
@pytest.fixture(autouse=True)
async def reset_database():
'''Automatically reset database before each test.'''
await database.execute('BEGIN')
yield
await database.execute('ROLLBACK')
The autouse=True means this fixture runs for every test automatically. Each test runs in a transaction that gets rolled back.
Gotcha 2: Application lifespan events
Starlette has lifespan events (startup and shutdown). By default, ASGITransport doesn't trigger these. If your app needs startup logic, use lifespan in your tests:
from contextlib import asynccontextmanager
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url='http://test') as c:
# Manually trigger startup/shutdown if needed
async with app.router.lifespan_context(app):
yield c
Or better yet, structure your app so tests can inject test-specific setup without relying on lifespan events.
Gotcha 3: Time-based tests
If your app does anything time-sensitive, mock the clock:
from unittest.mock import patch
from datetime import datetime
async def test_time_sensitive_feature(client):
frozen_time = datetime(2025, 1, 1, 12, 0, 0)
with patch('app.utils.get_current_time', return_value=frozen_time):
response = await client.get('/api/current-time')
data = response.json()
assert data['time'] == '2025-01-01T12:00:00'
Gotcha 4: Background tasks
Starlette background tasks run after the response is sent. In tests, they run immediately (since there's no actual network delay). This is usually what you want, but be aware:
async def test_background_task_runs(client):
response = await client.post('/send-email', json={'to': 'test@example.com'})
assert response.status_code == 200
# Background task has already run by this point
# Check that the side effect happened
assert email_was_sent('test@example.com')
Integration with other tools
Coverage
Use pytest-cov to track test coverage:
pytest --cov=app --cov-report=html
Since you're testing the real app with ASGITransport, you get accurate coverage numbers for your routes, middleware, and business logic.
pytest-xdist for parallel testing
Install pytest-xdist to run tests in parallel:
pip install pytest-xdist
pytest -n auto
The -n auto flag uses all your CPU cores. Since tests don't use the network and are independent, they parallelize beautifully. A test suite that takes 30 seconds can run in 5 seconds with parallelization.
Real-world example: Testing a blog API
Let's put it all together with a realistic example. Here's how I test the blog functionality on my site:
# tests/test_blog_api.py
import pytest
from httpx import AsyncClient, ASGITransport
from app.server import app
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url='http://test') as c:
yield c
class TestBlogList:
async def test_returns_all_posts(self, client):
response = await client.get('/api/posts')
assert response.status_code == 200
posts = response.json()
assert len(posts) > 0
assert all('title' in post for post in posts)
async def test_posts_sorted_by_date(self, client):
response = await client.get('/api/posts')
posts = response.json()
dates = [post['published_at'] for post in posts]
assert dates == sorted(dates, reverse=True)
async def test_posts_include_excerpts(self, client):
response = await client.get('/api/posts')
posts = response.json()
for post in posts:
assert 'excerpt' in post
assert len(post['excerpt']) < 200
class TestBlogPost:
async def test_valid_post_returns_200(self, client):
response = await client.get('/blog/httpx-for-testing-starlette')
assert response.status_code == 200
async def test_invalid_post_returns_404(self, client):
response = await client.get('/blog/does-not-exist')
assert response.status_code == 404
async def test_post_renders_markdown(self, client):
response = await client.get('/blog/httpx-for-testing-starlette')
assert '<h1>' in response.text # Markdown was rendered
assert '##' not in response.text # Raw markdown not shown
async def test_post_includes_metadata(self, client):
response = await client.get('/blog/httpx-for-testing-starlette')
assert 'python' in response.text.lower()
assert 'testing' in response.text.lower()
class TestBlogFiltering:
async def test_filter_by_category(self, client):
response = await client.get('/api/posts?category=python')
posts = response.json()
assert all(post['category'] == 'python' for post in posts)
async def test_filter_by_tag(self, client):
response = await client.get('/api/posts?tag=testing')
posts = response.json()
assert all('testing' in post['tags'] for post in posts)
async def test_empty_filter_returns_empty_list(self, client):
response = await client.get('/api/posts?category=nonexistent')
posts = response.json()
assert posts == []
This test suite covers happy paths, error cases, data validation, filtering, and rendering. It runs in under a second and catches bugs before they hit production.
Comparing to other testing approaches
TestClient vs ASGITransport
You might see examples using Starlette's built-in TestClient:
from starlette.testclient import TestClient
def test_homepage():
client = TestClient(app)
response = client.get('/')
assert response.status_code == 200
This works, but it's synchronous. You can't use it to test async endpoints properly. It also uses a different HTTP library (requests) instead of httpx. ASGITransport is the modern, async-native approach.
Spinning up a real server
Some people test by actually running uvicorn:
# Don't do this
def test_with_real_server():
subprocess.Popen(['uvicorn', 'app.server:app', '--port', '8001'])
time.sleep(2) # Wait for startup
response = requests.get('http://localhost:8001/')
assert response.status_code == 200
This is slow, flaky, and painful. Port conflicts. Race conditions. Cleanup issues. Just don't.
Advanced patterns
Dependency injection for tests
If your app uses dependency injection (common with FastAPI), you can override dependencies in tests:
from app.dependencies import get_database
async def get_test_database():
return MockDatabase()
@pytest.fixture
async def client():
app.dependency_overrides[get_database] = get_test_database
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url='http://test') as c:
yield c
app.dependency_overrides.clear()
This lets you inject test doubles without changing application code.
Snapshot testing
For complex HTML responses, use snapshot testing:
from syrupy import SnapshotAssertion
async def test_homepage_snapshot(client, snapshot: SnapshotAssertion):
response = await client.get('/')
assert response.text == snapshot
The first run captures the output. Future runs compare against it. Great for catching unintended changes.
Property-based testing
Combine with Hypothesis for property-based testing:
from hypothesis import given, strategies as st
@given(st.text(min_size=1, max_size=100))
async def test_search_handles_any_query(client, query):
response = await client.get(f'/search?q={query}')
assert response.status_code in [200, 400] # Never crashes
This generates random inputs to find edge cases you wouldn't think to test.
The bottom line
Testing Starlette apps with ASGITransport is the way to go. Fast, reliable, debuggable. No server to manage. No port conflicts. No flaky tests.
This pattern works for FastAPI too (since FastAPI is built on Starlette). It works for any ASGI framework. Once you set up the fixture, testing becomes almost pleasant.
The key insights:
- Use ASGITransport to bypass the network layer completely
- Set up pytest-asyncio so tests can be async
- Create reusable fixtures in conftest.py
- Test the real app with all middleware and error handlers
- Run tests in parallel with pytest-xdist for maximum speed
Your tests will be faster, more reliable, and easier to debug. You'll catch bugs earlier. You'll deploy with confidence. And you'll wonder how you ever tested web apps any other way.