Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 024 / 100

Testing FastAPI with pytest: what developers should know

Fast, readable API tests.

Expona Admin8 min read

Testing FastAPI with pytest: what developers should know—and honestly, this topic sits at the intersection of pragmatism and pain relief. When you're building APIs that handle real traffic, the confidence from solid tests is non-negotiable. We've all shipped endpoints that passed locally but crumbled under load, or watched auth logic behave differently in staging.

In this guide, we'll walk through the testing patterns we've refined across production e-commerce, SaaS, and ride-hailing systems. By the end, you'll know how to write fast, readable API tests that catch real bugs instead of false confidence.

TestClient vs httpx.AsyncClient: choosing the right tool

FastAPI ships with TestClient, which wraps your app and lets you make synchronous requests without spinning up a server. It's convenient, but it has a critical limitation: it doesn't actually run async code in an async context. For simple endpoints, this doesn't matter. For anything touching real async operations—database connections, external API calls, background tasks—you'll hit issues.

httpx.AsyncClient respects your async/await boundaries and runs your code the way production does. If your handlers use async def, dependencies run async queries, or middleware does async work, you should use AsyncClient. The tradeoff is that your test functions must be async too.

Here's the TestClient approach for a simple endpoint:

python
from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()

@app.get("/health")
def health():
    return {"status": "ok"}

client = TestClient(app)

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

Now the async version with httpx:

python
import pytest
import httpx
from contextlib import asynccontextmanager

app = FastAPI()

@app.get("/health")
async def health():
    return {"status": "ok"}

@pytest.fixture
async def async_client():
    async with httpx.AsyncClient(app=app, base_url="http://test") as client:
        yield client

@pytest.mark.asyncio
async def test_health(async_client):
    response = await async_client.get("/health")
    assert response.status_code == 200
    assert response.json() == {"status": "ok"}

Notice we need pytest-asyncio installed and the @pytest.mark.asyncio marker. In a real system, we almost always choose the async path because it matches production behavior. TestClient is fine for trivial endpoints or when you're certain nothing async runs under the hood.

Overriding dependencies for databases and authentication

One of FastAPI's best features is the dependency injection system. This also makes testing elegant: you override dependencies to inject test doubles. Instead of hitting your real database or authentication service, you provide mocks or fixtures that return predictable data.

Let's say you have a user service and an endpoint that fetches a user:

python
from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session

app = FastAPI()

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

def get_current_user(db: Session = Depends(get_db)):
    # In real flow, check JWT token, query database, etc.
    user = db.query(User).first()
    if not user:
        raise HTTPException(status_code=401)
    return user

@app.get("/profile")
def profile(user: User = Depends(get_current_user)):
    return {"id": user.id, "email": user.email}

To test this without a database, we override both get_db and get_current_user:

python
import pytest
from fastapi.testclient import TestClient
from unittest.mock import MagicMock

@pytest.fixture
def mock_user():
    return User(id=1, email="test@example.com")

@pytest.fixture
def client_with_auth(mock_user):
    def override_get_db():
        return MagicMock()
    
    def override_get_current_user():
        return mock_user
    
    app.dependency_overrides[get_db] = override_get_db
    app.dependency_overrides[get_current_user] = override_get_current_user
    
    client = TestClient(app)
    yield client
    
    app.dependency_overrides.clear()

def test_profile(client_with_auth, mock_user):
    response = client_with_auth.get("/profile")
    assert response.status_code == 200
    assert response.json()["email"] == mock_user.email

The key is app.dependency_overrides—a dict where keys are the original dependency functions and values are replacements. Always clear overrides in cleanup (using yield in fixtures) to avoid test pollution.

For more complex scenarios, you might override at multiple levels. If you have a service layer that calls the database, override the service instead of the database, so you still test your actual endpoint logic:

python
@pytest.fixture
def client_with_service_mock():
    mock_service = MagicMock()
    mock_service.get_user.return_value = User(id=1, email="test@example.com")
    
    app.dependency_overrides[get_user_service] = lambda: mock_service
    client = TestClient(app)
    yield client
    app.dependency_overrides.clear()

Fixtures for clients and test data

A well-structured test suite leans heavily on fixtures. They eliminate boilerplate, make tests readable, and keep data consistent. We typically create fixtures for the test client, authenticated clients at different permission levels, and realistic test data.

Here's a fixture setup we use across projects:

python
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

# In-memory SQLite for tests
SQLALCHEMY_TEST_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(
    SQLALCHEMY_TEST_DATABASE_URL, 
    connect_args={"check_same_thread": False}
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

@pytest.fixture(scope="function")
def db():
    Base.metadata.create_all(bind=engine)
    db = TestingSessionLocal()
    yield db
    db.close()
    Base.metadata.drop_all(bind=engine)

@pytest.fixture
def client(db):
    def override_get_db():
        try:
            yield db
        finally:
            pass
    
    app.dependency_overrides[get_db] = override_get_db
    yield TestClient(app)
    app.dependency_overrides.clear()

@pytest.fixture
def admin_user(db):
    user = User(email="admin@example.com", hashed_password="...", role="admin")
    db.add(user)
    db.commit()
    db.refresh(user)
    return user

@pytest.fixture
def authenticated_client(client, admin_user):
    # Create a token for the admin user and inject it
    token = create_access_token(data={"sub": admin_user.email})
    client.headers.update({"Authorization": f"Bearer {token}"})
    return client

Now a test that uses this fixture stack is clean and focused:

python
def test_create_order(authenticated_client, admin_user, db):
    response = authenticated_client.post(
        "/orders",
        json={"item_id": 1, "quantity": 2}
    )
    assert response.status_code == 201
    
    order = db.query(Order).first()
    assert order.user_id == admin_user.id
    assert order.quantity == 2

The fixture hierarchy is clear: db provides a fresh database, client wires it into the app, admin_user seeds data, and authenticated_client adds authorization headers. Each test only depends on what it needs.

Testing status codes and response bodies

Once you have a solid test client and fixtures, assertions are straightforward. But there are patterns that work better than others. Always check status codes first—they're your first signal that something broke. Then validate the response structure and data.

Here's a real-world example from an e-commerce API:

python
def test_checkout_success(authenticated_client, db):
    # Setup: add items to cart
    cart_response = authenticated_client.post(
        "/cart/items",
        json={"product_id": 1, "quantity": 2}
    )
    assert cart_response.status_code == 201
    
    # Main test: checkout
    response = authenticated_client.post("/checkout")
    assert response.status_code == 200
    
    data = response.json()
    assert "order_id" in data
    assert data["status"] == "completed"
    assert data["total"] > 0
    
    # Verify side effects
    order = db.query(Order).filter_by(id=data["order_id"]).first()
    assert order is not None
    assert order.status == "completed"

def test_checkout_insufficient_stock(authenticated_client):
    # Try to checkout with unavailable product
    response = authenticated_client.post(
        "/checkout",
        json={"product_id": 999}
    )
    assert response.status_code == 400
    
    data = response.json()
    assert "detail" in data
    assert "stock" in data["detail"].lower()

Notice the pattern: arrange setup, act with a request, assert on status and response body, then verify side effects in the database. For error cases, check both the status code and error message—don't just assume a 400 is enough. Real bugs hide in bad error messaging.

When testing paginated endpoints or complex responses, use structured assertions:

python
def test_list_orders_pagination(authenticated_client, db):
    # Seed 25 orders
    for i in range(25):
        order = Order(user_id=1, status="completed")
        db.add(order)
    db.commit()
    
    # Test first page
    response = authenticated_client.get("/orders?skip=0&limit=10")
    assert response.status_code == 200
    
    data = response.json()
    assert len(data["items"]) == 10
    assert data["total"] == 25
    assert data["skip"] == 0
    assert data["limit"] == 10
    
    # Test second page
    response = authenticated_client.get("/orders?skip=10&limit=10")
    data = response.json()
    assert len(data["items"]) == 10
    assert data["skip"] == 10

Common mistakes

We've seen these patterns trip up teams repeatedly:

  • Not clearing dependency overrides: If you override a dependency but don't clear it after the test, the next test sees the override. Use fixtures with yield to guarantee cleanup.
  • Using TestClient for async code: TestClient runs in a sync context, so async dependencies and middleware behave oddly. If your endpoint is async def, use AsyncClient.
  • Testing only the happy path: Write tests for error cases, boundary conditions, and auth failures. A 200 response doesn't mean your endpoint is correct.
  • Ignoring response structure: Check that responses match your Pydantic models. Test the presence and type of fields, not just values.
  • Hard-coded test data: Use factories or fixtures to generate test data. Hard-coded IDs or timestamps cause flaky tests.
  • Database pollution between tests: Use function-scoped fixtures that create and drop tables for each test. Shared state breeds mysterious failures.

Key takeaways

  • Choose TestClient for sync endpoints and simple cases; use httpx.AsyncClient when your code runs async (which is most of the time in modern FastAPI apps).
  • Lean on dependency injection overrides to swap out databases, services, and auth without mocking at the HTTP level.
  • Build a fixture hierarchy: start with database setup, then clients, then authenticated variants. Each test pulls only what it needs.
  • Assert on status codes, response bodies, and database side effects. Error cases deserve the same attention as success paths.
  • Keep fixtures function-scoped and clean up aggressively to prevent test pollution and flaky failures.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles