🎓 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
Testing ensures your FastAPI application works correctly, prevents regressions, and makes refactoring safe. Good tests increase confidence in code and catch bugs before production.
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 required testing dependencies for FastAPI.
pip install pytest pytest-asyncio pytest-cov httpx
# Testing dependencies
pytest==7.4.0
pytest-asyncio==0.21.1
pytest-cov==4.1.0
httpx==0.24.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 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, DELETE endpoints with different request bodies and parameters.
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 provide 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
@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.
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
from unittest.mock import patch, MagicMock
from app.email import register_user
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 email was called
assert mock_send.called
assert mock_send.call_args[0] == ("[email protected]", "Welcome")
assert user.username == "john"
def test_external_api_call():
"""Mock external API calls"""
with patch("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 temporarily replace a function during a test, then automatically restore it afterward.
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
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 app.main import app, get_db
from app.database import SessionLocal
def test_get_items(monkeypatch):
"""Override dependency for testing"""
def mock_get_db():
return SessionLocal()
# Override the dependency
app.dependency_overrides[get_db] = mock_get_db
client = TestClient(app)
response = client.get("/items/")
assert response.status_code == 200
# Clean up
app.dependency_overrides.clear()
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 AsyncClient
from app.main import app
@pytest.mark.asyncio
async def test_async_endpoint():
"""Test async endpoint with AsyncClient"""
async with AsyncClient(app=app, base_url="http://test") as ac:
response = await ac.get("/")
assert response.status_code == 200
# OR use TestClient (synchronous, but works with async endpoints)
from fastapi.testclient import TestClient
def test_with_testclient():
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
10Measuring Test Coverage
Use pytest-cov to measure how much of your code is covered by tests.
# 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@v2
- uses: actions/setup-python@v2
with:
python-version: '3.11'
- 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@v2
with:
file: ./coverage.xml
15Summary & Advanced Topics
Master testing to build reliable FastAPI applications. Next, explore integration testing with real databases and E2E testing with Selenium.
🚀 Congratulations! You now understand how to write comprehensive unit tests for FastAPI applications. Build with confidence knowing your code works!