Handbooks / FastAPI / Chapter 8
Testing and Deployment
7 pages · ~5 min✓ Reviewed
Builds on Database Migrations with Alembic. Next up: WebSockets and Streaming.
Testing Mental Model
1. What TestClient Is
TestClient(fromfastapi.testclient) wraps your ASGI app- Calls it in-process: no real server, no network port
- Built on httpx
- Synchronous: plain
deftest, noawait
def test_items(): with TestClient(app) as client: r = client.get('/items') assert r.status_code == 200
no await needed
| with TestClient(app) | bare TestClient(app) | |
|---|---|---|
| Startup/shutdown lifespan | runs | skipped |
2. Test Pyramid for APIs
- 1Unitpure functions, many
- 2Clientendpoints via TestClient
- 3Real networkfew
3. Common Mistake
- Testing against a running uvicorn server
- Using a real database in tests
- Result: tests are slow and flaky
Use with TestClient(app) as client or lifespan events never run.
TestClient and AsyncClient Workflow
1. TestClient basics
def test_create(client): r = client.post('/items', json={'name': 'x'}) assert r.status_code == 201 assert r.json()['name'] == 'x'
sync: plain def, no await
2. AsyncClient
- Needs transport=ASGITransport(app=app) and
base_url='http://test' - The
app=shortcut is removed in current httpx - Async tests need pytest-asyncio or the anyio marker
import pytest from httpx import AsyncClient, ASGITransport @pytest.mark.anyio async def test_root(): t = ASGITransport(app=app) async with AsyncClient(transport=t, base_url='http://test') as ac: r = await ac.get('/')
| TestClient | AsyncClient | |
|---|---|---|
| Test function | plain def | async def |
| Choose when | nothing to await | test awaits (async DB, async fixtures) |
Dependency Overrides and Fixtures
1. Overriding a dependency
app.dependency_overrides[get_db] = fake_get_dbswaps aDependscallable- The key is the function object, not a string
- Override the callable the route uses, e.g.
get_current_user, to skip auth
app.dependency_overrides[get_current_user] = lambda: User(id=1) r = client.get('/me')
no login needed in the test
2. Fixtures in conftest.py
- Always clear overrides after the test, or they leak into other tests
- Test DB: session in a transaction or in-memory SQLite, override
get_dbto yield it, roll back after - Scope: function (default) isolates tests; session speeds up setup like creating tables
@pytest.fixture def client(): with TestClient(app) as c: yield c app.dependency_overrides.clear()
clear runs after the test, even on failure
3. Mistakes
Settings with pydantic-settings
1. BaseSettings
- Install separately:
pip install pydantic-settings class Settings(BaseSettings): typed fields read env vars and.env- Env names are case-insensitive;
env_prefixaddsAPP_
from pydantic import SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file='.env', env_prefix='APP_') db_url: str api_key: SecretStr debug: bool = False
2. Precedence and failure
- Init argswins
- Env vars
- .env file
- Defaultslast
- Missing required field → ValidationError at startup (fail fast)
SecretStris masked in repr and logs; read with.get_secret_value()
3. FastAPI wiring
@lru_cache def get_settings(): return Settings() @app.get('/info') def info(s: Settings = Depends(get_settings)): ... # test app.dependency_overrides[get_settings] = lambda: Settings(db_url='sqlite://', api_key='x')
@lru_cache builds Settings once
Uvicorn, Gunicorn and Docker
1. Running Uvicorn
- Dev: --reload gives one process with auto reload, never for production
--host 0.0.0.0is needed inside containers--workers Nstarts several processes (orfastapi run --workers 4)- --reload and --workers cannot be combined
uvicorn app.main:app --reload uvicorn app.main:app --workers 4 gunicorn app.main:app -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000
dev, Uvicorn workers, Gunicorn manager
2. Workers and Gunicorn
- Gunicorn is a process manager using UvicornWorker
- Newer Uvicorn has its own supervisor, so Gunicorn is optional
- Workers are separate processes, no shared memory
- Caches and globals differ per worker: use Redis or a DB for shared state
3. Docker and Deployment
FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY . . CMD ["uvicorn","app.main:app","--host","0.0.0.0","--port","80"]
requirements first = cached layer; exec-form CMD
Cheat Sheet
1. Test clients and overrides
| TestClient | AsyncClient | |
|---|---|---|
| Test | plain def | async def + marker |
| Lifespan | only inside with | none |
| Setup | with TestClient(app) as c: | ASGITransport(app=app), base_url='http://test' |
- Override:
app.dependency_overrides[dep] = fake - Key is the original function; clear in teardown
@pytest.fixture def client(): with TestClient(app) as c: yield c app.dependency_overrides.clear()
2. Settings
class Settings(BaseSettings)with typed fieldsSettingsConfigDict(env_file='.env', env_prefix='APP_')
Check yourself
Your app fills app.state.cache in a startup lifespan. A test does client = TestClient(app) then client.get('/items') and fails with an AttributeError on the cache. Why?
- A bare
TestClient(app)skips lifespan events, so startup never ran. - Use
with TestClient(app) as client:.
One test sets app.dependency_overrides[get_db] = fake_get_db and never clears it. What happens to later tests?
- The override lives on the shared
app, so later tests still get the fake DB. - Clear it in fixture teardown:
app.dependency_overrides.clear().
A container runs CMD ["uvicorn","app.main:app"] and the logs look healthy, but the published port refuses connections. Why?
- Uvicorn binds
127.0.0.1by default, which is unreachable from outside the container. - Add
--host 0.0.0.0.