🎓 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
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
Slow, End-to-End
Medium-paced
Fast, Isolated
2Setting Up Pytest
Install pytest and the testing dependencies FastAPI needs.
pip install pytest pytest-asyncio pytest-cov httpx
# 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.
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}"}
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.
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.
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
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.
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
import requests
def get_external_data():
"""Fetch data from an external API"""
response = requests.get("https://api.example.com/data")
return response.json()
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.
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()
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.
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 Level | Interpretation |
|---|---|
| 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]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short
12Common Testing Patterns
| Pattern | Use Case | Example |
|---|---|---|
| Parametrized tests | Test multiple inputs | @pytest.mark.parametrize |
| Mocking | Replace external dependencies | unittest.mock.patch |
| Fixtures | Reusable test setup | @pytest.fixture |
| Markers | Categorize tests | @pytest.mark.slow |
| Assertions | Verify test results | assert 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.
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:
- FastAPI Testing docs — official guide to TestClient
- Pytest documentation — fixtures, markers, and parametrize
- httpx ASGITransport docs — async testing setup