Skip to content
Codeloom
FastAPI

Testing FastAPI Apps with TestClient and Pytest Fixtures

Build a robust test suite for FastAPI with TestClient, pytest fixtures, dependency overrides, and async testing patterns.

·6 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • Using TestClient for synchronous endpoint testing
  • Building reusable pytest fixtures for FastAPI
  • Overriding dependencies and testing with databases

Prerequisites

  • Basic FastAPI knowledge
  • Familiarity with pytest
  • Understanding of dependency injection in FastAPI

Why Test FastAPI Applications?

FastAPI’s type system and Pydantic validation catch many errors at development time, but they cannot catch business logic bugs, integration issues, or edge cases in your data flow. A solid test suite gives you confidence to refactor, deploy, and extend your API without regressions.

FastAPI provides TestClient (built on httpx) for testing endpoints without starting a real server.

Getting Started with TestClient

# app/main.py
from fastapi import FastAPI

app = FastAPI()

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

@app.get("/items/{item_id}")
async def get_item(item_id: int, q: str = None):
    result = {"item_id": item_id}
    if q:
        result["query"] = q
    return result
# tests/test_main.py
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

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

def test_get_item():
    response = client.get("/items/42")
    assert response.status_code == 200
    assert response.json() == {"item_id": 42}

def test_get_item_with_query():
    response = client.get("/items/42?q=search")
    assert response.status_code == 200
    assert response.json() == {"item_id": 42, "query": "search"}

def test_get_item_invalid_id():
    response = client.get("/items/not-a-number")
    assert response.status_code == 422  # Validation error

TestClient sends real HTTP requests to your app in-process. No server startup needed, and tests run fast.

Pytest Fixtures for Clean Test Setup

Fixtures let you share setup logic across tests without repeating code.

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

@pytest.fixture
def client():
    """Provides a fresh test client for each test."""
    with TestClient(app) as c:
        yield c

@pytest.fixture
def auth_headers():
    """Provides authentication headers for protected endpoints."""
    return {"Authorization": "Bearer test-token-123"}

@pytest.fixture
def sample_item():
    """Provides sample item data for creation tests."""
    return {
        "name": "Test Item",
        "price": 29.99,
        "description": "A test item for unit tests",
        "category": "testing"
    }
# tests/test_items.py
def test_create_item(client, sample_item, auth_headers):
    response = client.post(
        "/items",
        json=sample_item,
        headers=auth_headers
    )
    assert response.status_code == 201
    data = response.json()
    assert data["name"] == sample_item["name"]
    assert "id" in data

def test_create_item_missing_name(client, auth_headers):
    response = client.post(
        "/items",
        json={"price": 10.0},
        headers=auth_headers
    )
    assert response.status_code == 422

Overriding Dependencies

FastAPI’s dependency_overrides dictionary lets you swap any dependency for testing. This is how you avoid hitting real databases, APIs, or authentication services in tests.

# app/dependencies.py
from fastapi import Depends, HTTPException, Header

async def get_current_user(authorization: str = Header(...)):
    token = authorization.replace("Bearer ", "")
    user = await verify_token(token)
    if not user:
        raise HTTPException(status_code=401, detail="Invalid token")
    return user

async def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()
# tests/conftest.py
from app.dependencies import get_current_user, get_db
from app.main import app

class FakeUser:
    def __init__(self, id=1, username="testuser", is_admin=False):
        self.id = id
        self.username = username
        self.is_admin = is_admin

@pytest.fixture
def client():
    def override_user():
        return FakeUser()

    def override_db():
        db = TestingSessionLocal()
        try:
            yield db
        finally:
            db.close()

    app.dependency_overrides[get_current_user] = override_user
    app.dependency_overrides[get_db] = override_db

    with TestClient(app) as c:
        yield c

    app.dependency_overrides.clear()

@pytest.fixture
def admin_client():
    def override_admin():
        return FakeUser(id=99, username="admin", is_admin=True)

    app.dependency_overrides[get_current_user] = override_admin
    with TestClient(app) as c:
        yield c
    app.dependency_overrides.clear()

Now every test using the client fixture gets a fake user and test database automatically.

Testing with a Real Test Database

For integration tests, use a real database with test data that gets cleaned up between tests.

# tests/conftest.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.database import Base
from app.dependencies import get_db
from app.main import app

TEST_DATABASE_URL = "sqlite:///./test.db"

engine = create_engine(
    TEST_DATABASE_URL,
    connect_args={"check_same_thread": False}
)
TestingSessionLocal = sessionmaker(bind=engine)

@pytest.fixture(scope="session", autouse=True)
def create_tables():
    """Create all tables once for the test session."""
    Base.metadata.create_all(bind=engine)
    yield
    Base.metadata.drop_all(bind=engine)

@pytest.fixture
def db_session():
    """Provides a transactional database session that rolls back."""
    connection = engine.connect()
    transaction = connection.begin()
    session = TestingSessionLocal(bind=connection)

    yield session

    session.close()
    transaction.rollback()
    connection.close()

@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()

Each test gets a database session wrapped in a transaction that rolls back at the end. Tests never pollute each other’s data.

Async Testing with httpx

If your endpoints use async database operations or you need to test async code directly, use httpx.AsyncClient with pytest-asyncio.

# pip install httpx pytest-asyncio
import pytest
import httpx
from app.main import app

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

@pytest.mark.anyio
async def test_async_endpoint(async_client):
    response = await async_client.get("/health")
    assert response.status_code == 200
    assert response.json()["status"] == "healthy"

@pytest.mark.anyio
async def test_async_create_and_fetch(async_client):
    # Create
    create_response = await async_client.post(
        "/items",
        json={"name": "Async Item", "price": 15.0}
    )
    assert create_response.status_code == 201
    item_id = create_response.json()["id"]

    # Fetch
    get_response = await async_client.get(f"/items/{item_id}")
    assert get_response.status_code == 200
    assert get_response.json()["name"] == "Async Item"

Testing WebSocket Endpoints

TestClient supports WebSocket testing with a context manager.

def test_websocket_echo(client):
    with client.websocket_connect("/ws") as ws:
        ws.send_text("hello")
        data = ws.receive_text()
        assert data == "Echo: hello"

def test_websocket_json(client):
    with client.websocket_connect("/ws/data") as ws:
        ws.send_json({"action": "subscribe", "channel": "news"})
        response = ws.receive_json()
        assert response["status"] == "subscribed"
        assert response["channel"] == "news"

def test_websocket_disconnect():
    client = TestClient(app)
    with client.websocket_connect("/ws/chat/test?username=tester") as ws:
        ws.send_text("hello room")
        data = ws.receive_text()
        assert "hello room" in data
    # Connection closed automatically when exiting context

Testing Error Responses and Edge Cases

Cover the unhappy paths — they are where bugs hide.

def test_not_found(client):
    response = client.get("/items/99999")
    assert response.status_code == 404
    assert response.json()["detail"] == "Item not found"

def test_duplicate_creation(client, sample_item):
    # First creation succeeds
    client.post("/items", json=sample_item)

    # Duplicate should fail
    response = client.post("/items", json=sample_item)
    assert response.status_code == 409
    assert "already exists" in response.json()["detail"]

def test_unauthorized_access():
    client = TestClient(app)  # No dependency override
    response = client.get(
        "/admin/settings",
        headers={"Authorization": "Bearer invalid-token"}
    )
    assert response.status_code == 401

@pytest.mark.parametrize("invalid_price", [-1, 0, "abc", None])
def test_invalid_prices(client, invalid_price):
    response = client.post(
        "/items",
        json={"name": "Test", "price": invalid_price}
    )
    assert response.status_code == 422

Parametrized tests are especially useful for validation testing. One test function covers multiple invalid inputs.

Structuring Your Test Suite

tests/
    conftest.py          # Shared fixtures
    test_health.py       # Health and status endpoints
    test_items.py        # Item CRUD tests
    test_auth.py         # Authentication and authorization
    test_websockets.py   # WebSocket endpoint tests
    test_integration.py  # Full workflow integration tests
    factories.py         # Test data factories
# tests/factories.py
from app.models import Item, User

def make_item(**overrides):
    defaults = {
        "name": "Default Item",
        "price": 19.99,
        "description": "Default description",
        "category": "general"
    }
    defaults.update(overrides)
    return Item(**defaults)

def make_user(**overrides):
    defaults = {
        "username": "testuser",
        "email": "test@example.com",
        "is_admin": False
    }
    defaults.update(overrides)
    return User(**defaults)

Key Takeaways

FastAPI testing revolves around three core tools: TestClient for synchronous endpoint tests, dependency_overrides for isolating tests from real services, and pytest fixtures for sharing setup logic. Use transactional database sessions that roll back to keep tests independent. Cover both happy paths and error cases, and use parametrized tests for validation logic. Start with unit-level endpoint tests and add integration tests for complete workflows.