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

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

E2E

Slow, End-to-End

Integration

Medium-paced

Unit Tests

Fast, Isolated

2Setting Up Pytest

Install pytest and required testing dependencies for FastAPI.

bash
pip install pytest pytest-asyncio pytest-cov httpx
requirements-dev.txt
# 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.

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 results.

bash
# 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.

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 provide 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

@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
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
tests/test_users.py
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.

app/main.py
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()
tests/test_items.py
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.

tests/test_async.py
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.

bash
# 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.

bash
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@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!

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.