FastAPI

FastAPI Unit Testing — Comprehensive Testing Strategies

Thirdy Gayares
16 min read

🎓 What You Will Learn

  • Pytest Basics: Writing test functions with fixtures
  • Testing Endpoints: Using TestClient to test API routes
  • Mocking: Mocking external services and dependencies
  • Fixtures: Creating reusable test setup and teardown
  • Test Coverage: Measuring code coverage with pytest-cov
  • CI/CD Integration: Running tests in GitHub Actions
TestingPytestQualityCoverage

1Why Testing Matters

Tests prove your FastAPI app works. They catch bugs before your users do. They also make it safe to change code later.

Test-Driven Development: Write tests before writing code to clarify requirements and design better APIs.

Testing Pyramid

E2E

Slow, End-to-End

Integration

Medium-paced

Unit Tests

Fast, Isolated

2Setting Up Pytest

Install pytest and the testing dependencies FastAPI needs.

pip install pytest pytest-asyncio pytest-cov httpx
requirements-dev.txt
# Testing dependencies
pytest==8.3.4
pytest-asyncio==0.25.3
pytest-cov==6.0.0
httpx==0.28.1

3Writing Your First Test

Create a simple test file that tests a FastAPI endpoint using TestClient.

app/main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
async def root():
    return {"message": "Hello World"}

@app.get("/items/{item_id}")
async def get_item(item_id: int):
    return {"item_id": item_id, "name": f"Item {item_id}"}
tests/test_main.py
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_root():
    """Test the root endpoint"""
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"message": "Hello World"}

def test_get_item():
    """Test getting an item"""
    response = client.get("/items/42")
    assert response.status_code == 200
    assert response.json() == {"item_id": 42, "name": "Item 42"}

TestClient: FastAPI provides TestClient to test endpoints without starting a server. It runs the app in-process.

4Running Tests

Run tests with pytest and see the results.

# Run all tests
pytest

# Run with verbose output
pytest -v

# Run specific test file
pytest tests/test_main.py

# Run specific test function
pytest tests/test_main.py::test_root

# Run with coverage
pytest --cov=app tests/

# Run and show print statements
pytest -s

5Testing Different Endpoint Types

Test GET, POST, PUT, and DELETE endpoints with different request bodies and parameters.

These tests assume your app already has CRUD endpoints for /items/. Use them as a pattern for your own routes.

tests/test_crud.py
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_create_item():
    """Test POST endpoint"""
    response = client.post(
        "/items/",
        json={"name": "Test Item", "price": 9.99}
    )
    assert response.status_code == 201
    assert response.json()["name"] == "Test Item"

def test_update_item():
    """Test PUT endpoint"""
    response = client.put(
        "/items/1",
        json={"name": "Updated Item", "price": 19.99}
    )
    assert response.status_code == 200
    assert response.json()["name"] == "Updated Item"

def test_delete_item():
    """Test DELETE endpoint"""
    response = client.delete("/items/1")
    assert response.status_code == 204

def test_list_items_with_query():
    """Test GET with query parameters"""
    response = client.get("/items/?skip=0&limit=10")
    assert response.status_code == 200
    assert isinstance(response.json(), list)

6Pytest Fixtures for Setup & Teardown

Fixtures are reusable setup code for tests. Use them to create test data, mock objects, or configure the app.

tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.database import SessionLocal
from app.models import User
from app.auth import create_access_token

@pytest.fixture
def client():
    """Provide a TestClient for all tests"""
    return TestClient(app)

@pytest.fixture
def db():
    """Provide a test database session"""
    db = SessionLocal()
    yield db
    db.close()

@pytest.fixture
def test_user(db):
    """Create a test user"""
    user = User(username="testuser", email="[email protected]")
    db.add(user)
    db.commit()
    db.refresh(user)
    return user

@pytest.fixture
def authenticated_client(client, test_user):
    """Provide a client authenticated as test_user"""
    token = create_access_token(test_user.id)
    client.headers["Authorization"] = f"Bearer {token}"
    return client
tests/test_users.py
def test_get_profile(authenticated_client):
    """Use authenticated_client fixture"""
    response = authenticated_client.get("/users/me")
    assert response.status_code == 200
    assert response.json()["username"] == "testuser"

7Mocking External Dependencies

Use unittest.mock to mock external services like APIs, databases, or email services.

app/email.py
from app.crud import create_user

def send_email(recipient: str, subject: str, body: str):
    """Send email via external service"""
    # In real code, calls email provider API
    pass

def register_user(username: str, email: str):
    """Register user and send welcome email"""
    user = create_user(username, email)
    send_email(email, "Welcome", "Welcome to our service")
    return user
app/external.py
import requests

def get_external_data():
    """Fetch data from an external API"""
    response = requests.get("https://api.example.com/data")
    return response.json()
tests/test_users.py
from unittest.mock import patch
from app.email import register_user
from app.external import get_external_data

def test_register_user():
    """Test user registration with mocked email"""
    with patch("app.email.send_email") as mock_send:
        user = register_user("john", "[email protected]")

        # Verify the email was sent with the right arguments
        mock_send.assert_called_once_with(
            "[email protected]", "Welcome", "Welcome to our service"
        )
        assert user.username == "john"

def test_external_api_call():
    """Mock external API calls"""
    with patch("app.external.requests.get") as mock_get:
        # Configure mock
        mock_get.return_value.json.return_value = {"id": 1, "name": "test"}

        # Call code that uses requests.get
        result = get_external_data()

        # Verify it was called correctly
        assert result["name"] == "test"
        mock_get.assert_called_once()

patch() as context manager: Use with patch(...) as mock: to replace a function only during the test. Python restores the real function after the block ends.

Common mistake: Patch where the function is used, not where it is defined. We patch app.email.send_email because register_user lives in app.email.

8Testing with Dependency Injection

Use FastAPI's dependency injection to override dependencies in tests.

app/main.py
from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Item

app = FastAPI()

@app.get("/items/")
async def get_items(db: Session = Depends(get_db)):
    return db.query(Item).all()
tests/test_items.py
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

# Use a separate test database, never your real one
test_engine = create_engine("sqlite:///./test.db")
TestingSessionLocal = sessionmaker(bind=test_engine)
Base.metadata.create_all(bind=test_engine)

def override_get_db():
    """Yield a test session, then always close it"""
    db = TestingSessionLocal()
    try:
        yield db
    finally:
        db.close()

def test_get_items():
    # Replace the real database dependency
    app.dependency_overrides[get_db] = override_get_db

    client = TestClient(app)
    response = client.get("/items/")
    assert response.status_code == 200

    # Clean up
    app.dependency_overrides.clear()

How it works: app.dependency_overrides is a dict. The key is the real dependency function. The value is your test version. Always call clear() after the test.

9Testing Async Endpoints

FastAPI uses async functions. TestClient handles this automatically, but you can also write async tests with pytest-asyncio.

tests/test_async.py
import pytest
from httpx import ASGITransport, AsyncClient
from fastapi.testclient import TestClient
from app.main import app

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

# OR use TestClient (synchronous, but works with async endpoints)
def test_with_testclient():
    client = TestClient(app)
    response = client.get("/")
    assert response.status_code == 200

httpx 0.28 change: AsyncClient(app=app) was removed in httpx 0.28. Use AsyncClient(transport=ASGITransport(app=app)) instead. TestClient(app) still works the same.

10Measuring Test Coverage

Use pytest-cov to see how much of your code your tests cover.

# Run tests with coverage report
pytest --cov=app --cov-report=html

# View coverage in terminal
pytest --cov=app

# Specify minimum coverage threshold
pytest --cov=app --cov-fail-under=80
Coverage LevelInterpretation
90-100%Excellent coverage, confident code
70-90%Good coverage, acceptable
50-70%Medium coverage, could be better
Below 50%Poor coverage, risky to modify

11Organizing Tests

Structure your test files to match your application structure.

project/
├── app/
│   ├── main.py
│   ├── models.py
│   ├── schemas.py
│   ├── crud.py
│   └── database.py
├── tests/
│   ├── conftest.py          # Shared fixtures
│   ├── test_main.py         # Test endpoints
│   ├── test_models.py       # Test database models
│   ├── test_schemas.py      # Test request/response validation
│   ├── test_crud.py         # Test database operations
│   └── test_integration.py  # Integration tests
└── pytest.ini               # Pytest configuration
pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short

12Common Testing Patterns

PatternUse CaseExample
Parametrized testsTest multiple inputs@pytest.mark.parametrize
MockingReplace external dependenciesunittest.mock.patch
FixturesReusable test setup@pytest.fixture
MarkersCategorize tests@pytest.mark.slow
AssertionsVerify test resultsassert response.status_code == 200

13Testing Best Practices

  • Write tests for happy path and error cases
  • Use fixtures to reduce code duplication
  • Mock external services (APIs, databases, emails)
  • Test edge cases (null values, empty lists, negative numbers)
  • Keep tests small and focused on one thing
  • Use descriptive test names that explain what is tested
  • Run tests frequently during development
  • Aim for 80%+ code coverage
  • Test both success and failure scenarios
  • Use parametrized tests to test multiple inputs

14CI/CD Integration with GitHub Actions

Run tests automatically on every commit to catch bugs early.

.github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: |
          pip install -r requirements.txt
          pip install -r requirements-dev.txt

      - name: Run tests with coverage
        run: |
          pytest --cov=app --cov-report=xml

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage.xml

15Summary & Advanced Topics

You now know the core of testing FastAPI apps. Next, explore integration tests with a real database and E2E tests with tools like Playwright.

🚀 Congratulations! You now understand how to write unit tests for FastAPI applications. Build with confidence knowing your code works!

Helpful resources:

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.