FastAPI’s test utilities make it straightforward to write unit and integration tests without running a live server.

Setup

  pip install pytest httpx pytest-asyncio
  

FastAPI includes TestClient based on Starlette — no server needed.

Basic Tests

  # tests/test_main.py
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_read_root():
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"message": "Hello, FastAPI!"}

def test_create_item():
    response = client.post("/items/", json={
        "name": "Widget",
        "price": 9.99,
    })
    assert response.status_code == 201
    data = response.json()
    assert data["name"] == "Widget"
    assert "id" in data

def test_validation_error():
    response = client.post("/items/", json={"name": ""})
    assert response.status_code == 422
  

Run: pytest tests/ -v

Override Dependencies

Replace database or auth dependencies in tests:

  from app.database import get_db
from app.main import app

def override_get_db():
    db = TestSessionLocal()
    try:
        yield db
    finally:
        db.close()

app.dependency_overrides[get_db] = override_get_db

client = TestClient(app)

def test_with_test_db():
    response = client.get("/items/")
    assert response.status_code == 200
  

Reset overrides after tests:

  import pytest

@pytest.fixture(autouse=True)
def reset_overrides():
    yield
    app.dependency_overrides.clear()
  

Fixtures for Test Data

  # tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.main import app
from app.database import Base, get_db

SQLALCHEMY_TEST_URL = "sqlite:///:memory:"
engine = create_engine(SQLALCHEMY_TEST_URL, connect_args={"check_same_thread": False})
TestSession = sessionmaker(bind=engine)

@pytest.fixture
def db_session():
    Base.metadata.create_all(bind=engine)
    session = TestSession()
    yield session
    session.close()
    Base.metadata.drop_all(bind=engine)

@pytest.fixture
def client(db_session):
    def override_db():
        yield db_session

    app.dependency_overrides[get_db] = override_db
    with TestClient(app) as c:
        yield c
    app.dependency_overrides.clear()

@pytest.fixture
def sample_item(client):
    response = client.post("/items/", json={"name": "Test", "price": 1.0})
    return response.json()
  
  # tests/test_items.py
def test_get_item(client, sample_item):
    response = client.get(f"/items/{sample_item['id']}")
    assert response.status_code == 200
    assert response.json()["name"] == "Test"

def test_delete_item(client, sample_item):
    response = client.delete(f"/items/{sample_item['id']}")
    assert response.status_code == 204
  

Mock External Services

  from unittest.mock import patch, AsyncMock

@patch("app.services.external_api.fetch_data")
def test_with_mocked_api(mock_fetch, client):
    mock_fetch.return_value = {"status": "ok", "data": [1, 2, 3]}
    response = client.get("/external-data")
    assert response.status_code == 200
    assert len(response.json()["data"]) == 3
  

Testing Authentication

  @pytest.fixture
def auth_headers(client):
    response = client.post("/token", data={
        "username": "testuser",
        "password": "testpass",
    })
    token = response.json()["access_token"]
    return {"Authorization": f"Bearer {token}"}

def test_protected_route(client, auth_headers):
    response = client.get("/me", headers=auth_headers)
    assert response.status_code == 200
    assert response.json()["username"] == "testuser"

def test_unauthenticated(client):
    response = client.get("/me")
    assert response.status_code == 401
  

Async Tests

  import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app

@pytest.mark.asyncio
async def test_async_endpoint():
    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
    ) as ac:
        response = await ac.get("/async-endpoint")
    assert response.status_code == 200
  

Test Coverage

  pip install pytest-cov
pytest tests/ --cov=app --cov-report=html
open htmlcov/index.html
  

Test Organization

  tests/
├── conftest.py          # shared fixtures
├── test_main.py         # root endpoints
├── test_items.py        # item CRUD
├── test_auth.py         # authentication
└── test_services.py     # business logic (no HTTP)
  

Best Practices

  1. Test behavior, not implementation — tests should survive refactors
  2. Use fixtures for setup/teardown — avoid duplication
  3. One concept per test — clear failure messages
  4. Test error paths — 404, 422, 401, 500
  5. Use in-memory SQLite for fast database tests
  6. Mock external APIs — tests should not depend on network
  7. Run tests in CI — every pull request

Testing is non-negotiable for production FastAPI applications.