FastAPI Complete Course FastAPI Module 15

FastAPI Complete Course Module 15: Testing FastAPI Applications

Introduction

Welcome to Module 15 of the FastAPI Complete Course. In this chapter, we dive into one of the most critical phases of professional software development: testing. If you’re building APIs for a job or a production project, writing tests is non-negotiable. Tests ensure your code works as expected, prevent regressions, and give you the confidence to refactor or add new features.

In this tutorial, we will cover the essential testing strategies for FastAPI applications. You will learn how to write unit tests, use Pytest (the de facto Python testing framework), test your API endpoints with TestClient, mock your database, and measure test coverage. By the end of this chapter, you will be able to write robust tests for any FastAPI project, making you a more reliable and job-ready developer.

Unit Testing

Unit testing focuses on testing individual components of your code in isolation. In FastAPI, this often means testing your service functions, helper utilities, or business logic without involving the database or network calls.

What Makes a Good Unit Test?

  • Isolated: It does not depend on external systems (databases, APIs, file systems).
  • Fast: It runs in milliseconds.
  • Repeatable: It produces the same result every time.
  • Readable: It clearly describes what is being tested.

Example: Testing a Simple Utility Function

Let’s say we have a utility function that calculates a discount for a user.

# app/utils.py
def calculate_discount(price: float, user_tier: str) -> float:
    if user_tier == "gold":
        return price * 0.8
    elif user_tier == "silver":
        return price * 0.9
    else:
        return price

Now, we write a unit test for it.

# tests/test_utils.py
from app.utils import calculate_discount

def test_calculate_discount_gold():
    result = calculate_discount(100.0, "gold")
    assert result == 80.0

def test_calculate_discount_silver():
    result = calculate_discount(100.0, "silver")
    assert result == 90.0

def test_calculate_discount_basic():
    result = calculate_discount(100.0, "basic")
    assert result == 100.0

Explanation: Each test function calls calculate_discount with specific inputs and uses assert to verify the output. These tests are fast and isolated—no database or server needed.

Pytest Basics

Pytest is the most popular testing framework for Python. It makes writing and running tests simple and powerful. FastAPI’s own testing utilities are built on top of Pytest.

Installing Pytest

pip install pytest

Key Pytest Features

  • Auto-discovery: Pytest automatically finds test files named test_*.py or *_test.py.
  • Assertions: Use Python’s built-in assert statement—no need for special methods.
  • Fixtures: Reusable setup and teardown logic.
  • Parametrization: Run the same test with different inputs.

Running Tests

# Run all tests in the tests/ directory
pytest tests/

# Run with verbose output
pytest -v tests/

# Run a specific test file
pytest tests/test_utils.py

# Run a specific test function
pytest tests/test_utils.py::test_calculate_discount_gold

Example: Using Parametrization

Instead of writing three separate tests, we can use @pytest.mark.parametrize.

import pytest
from app.utils import calculate_discount

@pytest.mark.parametrize("price, tier, expected", [
    (100.0, "gold", 80.0),
    (100.0, "silver", 90.0),
    (100.0, "basic", 100.0),
    (0.0, "gold", 0.0),
])
def test_calculate_discount(price, tier, expected):
    assert calculate_discount(price, tier) == expected

Why this matters: Parametrization reduces code duplication and makes it easy to add new test cases.

API Testing

API testing verifies that your FastAPI endpoints work correctly. FastAPI provides a TestClient class (based on Starlette’s test client) that lets you simulate HTTP requests without running a live server.

Setting Up TestClient

First, install httpx (TestClient requires it).

pip install httpx

Basic API Test Example

Assume we have a simple FastAPI app.

# app/main.py
from fastapi import FastAPI

app = FastAPI()

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

@app.get("/items/{item_id}")
def read_item(item_id: int, q: str = None):
    return {"item_id": item_id, "q": q}

Now, let’s test these endpoints.

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

client = TestClient(app)

def test_read_root():
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"message": "Hello World"}

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

def test_read_item_no_query():
    response = client.get("/items/1")
    assert response.status_code == 200
    assert response.json() == {"item_id": 1, "q": None}

Explanation:

  • We create a TestClient instance, passing our FastAPI app.
  • We call client.get() to simulate GET requests.
  • We check the status code and JSON response.

Testing POST, PUT, DELETE

Here’s an example with a POST endpoint.

# app/main.py (add this)
from pydantic import BaseModel

class Item(BaseModel):
    name: str
    price: float

@app.post("/items/")
def create_item(item: Item):
    return {"name": item.name, "price": item.price}
# tests/test_api.py (add this)
def test_create_item():
    response = client.post("/items/", json={"name": "Laptop", "price": 999.99})
    assert response.status_code == 200
    data = response.json()
    assert data["name"] == "Laptop"
    assert data["price"] == 999.99

Key point: Use the json parameter to send JSON data in the request body.

Mocking Database

In real-world applications, your endpoints often interact with a database. During testing, you should mock the database to avoid side effects and speed up tests. Mocking replaces real dependencies with fake objects that behave predictably.

Why Mock?

  • Tests run without a real database.
  • You can simulate error conditions (e.g., database timeout).
  • Tests become deterministic and fast.

Example: A Service with Database Dependency

Let’s create a simple user service that depends on a database session.

# app/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# app/models.py
from sqlalchemy import Column, Integer, String
from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()

class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True, index=True)
    name = Column(String)
    email = Column(String)
# app/services.py
from sqlalchemy.orm import Session
from app.models import User

def get_user_by_email(db: Session, email: str):
    return db.query(User).filter(User.email == email).first()
# app/main.py (add this)
from fastapi import Depends
from app.database import SessionLocal
from app.services import get_user_by_email

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

@app.get("/users/{email}")
def read_user(email: str, db: Session = Depends(get_db)):
    user = get_user_by_email(db, email)
    if user is None:
        return {"error": "User not found"}
    return {"id": user.id, "name": user.name, "email": user.email}

Mocking the Database with unittest.mock

We’ll use Python’s unittest.mock to mock the database session and the service function.

# tests/test_mocked_api.py
from fastapi.testclient import TestClient
from unittest.mock import patch, MagicMock
from app.main import app

client = TestClient(app)

def test_read_user_found():
    # Create a mock user object
    mock_user = MagicMock()
    mock_user.id = 1
    mock_user.name = "Alice"
    mock_user.email = "alice@example.com"

    # Patch the service function to return the mock user
    with patch("app.services.get_user_by_email", return_value=mock_user):
        response = client.get("/users/alice@example.com")
        assert response.status_code == 200
        assert response.json() == {"id": 1, "name": "Alice", "email": "alice@example.com"}

def test_read_user_not_found():
    # Patch the service function to return None
    with patch("app.services.get_user_by_email", return_value=None):
        response = client.get("/users/nonexistent@example.com")
        assert response.status_code == 200
        assert response.json() == {"error": "User not found"}

Explanation:

  • patch("app.services.get_user_by_email", return_value=...) replaces the real function with a mock that returns a predefined value.
  • The mock object simulates a database user without hitting the actual database.
  • We test both the “found” and “not found” scenarios.

Using Fixtures for Reusable Mocks

Fixtures make your tests cleaner and more reusable.

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

@pytest.fixture
def client():
    return TestClient(app)

@pytest.fixture
def mock_db_user():
    user = MagicMock()
    user.id = 1
    user.name = "Bob"
    user.email = "bob@example.com"
    return user
# tests/test_with_fixtures.py
from unittest.mock import patch

def test_read_user_found(client, mock_db_user):
    with patch("app.services.get_user_by_email", return_value=mock_db_user):
        response = client.get("/users/bob@example.com")
        assert response.status_code == 200
        assert response.json()["name"] == "Bob"

Best practice: Keep fixtures in conftest.py so they are available to all test files in the same directory.

Test Coverage

Test coverage measures how much of your code is executed by your tests. It helps identify untested parts of your application. However, 100% coverage does not guarantee bug-free code—it only tells you which lines were run.

Installing Coverage.py

pip install pytest-cov

Running Coverage with Pytest

# Run tests and show coverage report in terminal
pytest --cov=app tests/

# Generate an HTML report for visual inspection
pytest --cov=app --cov-report=html tests/

The HTML report will be in the htmlcov/ directory. Open index.html in your browser to see which lines are covered (green) and which are not (red).

Setting a Coverage Threshold

You can enforce a minimum coverage percentage in your pyproject.toml or setup.cfg.

# setup.cfg
[tool:pytest]
addopts = --cov=app --cov-fail-under=80

Now, if coverage drops below 80%, the test suite will fail.

What to Aim For?

  • 80-90% is a good target for most projects.
  • Focus on testing business logic and API endpoints.
  • Don’t obsess over covering trivial getters/setters or generated code.

Common Mistakes

  1. Testing with a real database: This makes tests slow and fragile. Always mock external dependencies.
  2. Not using fixtures: Repeating setup code in every test leads to maintenance nightmares.
  3. Ignoring edge cases: Test empty inputs, invalid data, missing parameters, and error responses.
  4. Over-mocking: Mocking too many things can hide real integration issues. Test the actual logic where possible.
  5. Forgetting to check status codes: Always assert the HTTP status code, not just the response body.
  6. Running tests against a live server: Use TestClient instead of starting uvicorn manually.

Practice Task

Now it’s your turn. Build and test a small FastAPI application with the following requirements:

  1. Create a FastAPI app with two endpoints:
    • POST /tasks/: Accepts a JSON body with title (string) and completed (boolean, default False). Returns the created task with an id (integer).
    • GET /tasks/{task_id}: Returns a task by ID. Return 404 if not found.
  2. Use a service layer (e.g., app/services.py) that stores tasks in a Python dictionary (simulating a database).
  3. Write the following tests:
    • Test creating a task.
    • Test retrieving an existing task.
    • Test retrieving a non-existent task (expect 404).
    • Mock the service layer using unittest.mock.patch.
  4. Run coverage and ensure your tests cover at least 90% of your application code.

Hint: Use pytest --cov=app --cov-report=term-missing to see which lines are missing coverage.

Summary

In this module, you learned the foundations of testing FastAPI applications:

  • Unit testing for isolated logic.
  • Pytest basics including parametrization and fixtures.
  • API testing with TestClient.
  • Mocking databases to keep tests fast and reliable.
  • Test coverage to measure and improve your test suite.

Testing is a skill that separates professional developers from amateurs. Master it, and you’ll write code that is robust, maintainable, and ready for production.

FAQs

1. What is the difference between unit testing and API testing?

Unit testing tests individual functions or methods in isolation, often mocking external dependencies. API testing (or integration testing) tests the full HTTP endpoint, including routing, request parsing, and response generation.

2. Do I need to test every single endpoint?

Yes, in a professional setting, every endpoint should have at least one test for the happy path and one for the error path (e.g., 404, 422 validation errors).

3. Can I use pytest fixtures to create test data?

Absolutely! Fixtures are the recommended way to set up test data, mock dependencies, and create reusable components like a TestClient instance.

4. How do I test authentication in FastAPI?

You can mock the dependency that extracts the current user. For example, if you have a get_current_user dependency, patch it to return a fake user object during tests.

5. What if my tests are slow?

Slow tests are usually caused by real I/O (database, network). Mock those dependencies. If you must test against a real database, use an in-memory SQLite database or a test container.

Congratulations on completing Module 15! You are now equipped to write professional-grade tests for FastAPI. In Module 16: Deployment and Production, you’ll learn how to containerize your app with Docker, deploy to cloud platforms, and set up CI/CD pipelines. See you there!

Additional Practical Example

Let’s build a more comprehensive test suite for a real-world API endpoint that handles user registration. This example will demonstrate how to test database interactions, validation, and error handling in a single flow.

# test_user_registration.py
import pytest
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
from app.models import User
from app.schemas import UserCreate

# Setup test database
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

# Override dependency
def override_get_db():
    try:
        db = TestingSessionLocal()
        yield db
    finally:
        db.close()

app.dependency_overrides[get_db] = override_get_db

client = TestClient(app)

@pytest.fixture(autouse=True)
def setup_database():
    Base.metadata.create_all(bind=engine)
    yield
    Base.metadata.drop_all(bind=engine)

def test_register_user_success():
    """Test successful user registration with valid data."""
    response = client.post(
        "/register",
        json={
            "username": "testuser",
            "email": "test@example.com",
            "password": "SecurePass123!",
            "full_name": "Test User"
        }
    )
    assert response.status_code == 201
    data = response.json()
    assert data["username"] == "testuser"
    assert data["email"] == "test@example.com"
    assert "id" in data
    assert "password" not in data  # Password should never be returned

def test_register_user_duplicate_email():
    """Test that duplicate email raises 400 error."""
    # First registration
    client.post(
        "/register",
        json={
            "username": "user1",
            "email": "duplicate@example.com",
            "password": "SecurePass123!"
        }
    )
    
    # Second registration with same email
    response = client.post(
        "/register",
        json={
            "username": "user2",
            "email": "duplicate@example.com",
            "password": "AnotherPass456!"
        }
    )
    assert response.status_code == 400
    assert "Email already registered" in response.json()["detail"]

def test_register_user_weak_password():
    """Test that weak passwords are rejected."""
    response = client.post(
        "/register",
        json={
            "username": "weakuser",
            "email": "weak@example.com",
            "password": "123",
            "full_name": "Weak Password User"
        }
    )
    assert response.status_code == 422  # Validation error
    errors = response.json()["detail"]
    assert any("password" in error["loc"] for error in errors)

def test_register_user_missing_fields():
    """Test that missing required fields return validation errors."""
    response = client.post(
        "/register",
        json={"username": "incomplete"}
    )
    assert response.status_code == 422
    errors = response.json()["detail"]
    missing_fields = ["email", "password"]
    for field in missing_fields:
        assert any(field in error["loc"] for error in errors)

def test_register_user_invalid_email():
    """Test that invalid email format is rejected."""
    response = client.post(
        "/register",
        json={
            "username": "bademail",
            "email": "not-an-email",
            "password": "SecurePass123!"
        }
    )
    assert response.status_code == 422
    assert any("email" in error["loc"] for error in response.json()["detail"])

This example demonstrates several important testing patterns:

  • Database setup/teardown: The setup_database fixture creates fresh tables before each test and drops them after, ensuring test isolation.
  • Dependency override: We replace the production database with a test SQLite database to avoid affecting real data.
  • Comprehensive validation: Tests cover success cases, duplicate data, weak passwords, missing fields, and invalid formats.
  • Security checking: The success test verifies that passwords are never returned in the response.

When running these tests, you’ll see clear output indicating which test passed or failed. The autouse=True fixture ensures the database is always clean, preventing test pollution.

Class-Based Implementation Example

While function-based tests are common, FastAPI also supports class-based test organization. This approach can be beneficial when you have related tests that share setup logic or when you want to group tests logically.

# test_todo_api.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.database import get_db
from app.models import Todo, User
from app.schemas import TodoCreate

class TestTodoAPI:
    """Class-based test suite for Todo API endpoints."""
    
    @pytest.fixture(autouse=True)
    def setup_method(self, test_db):
        """Setup runs before each test method."""
        self.client = TestClient(app)
        self.db = test_db
        # Create a test user and get token
        self.test_user = self._create_test_user()
        self.token = self._get_token()
        self.headers = {"Authorization": f"Bearer {self.token}"}
    
    def _create_test_user(self):
        """Helper to create a test user."""
        response = self.client.post(
            "/register",
            json={
                "username": "testuser",
                "email": "test@example.com",
                "password": "TestPass123!"
            }
        )
        return response.json()
    
    def _get_token(self):
        """Helper to get authentication token."""
        response = self.client.post(
            "/token",
            data={
                "username": "testuser",
                "password": "TestPass123!"
            }
        )
        return response.json()["access_token"]
    
    def test_create_todo(self):
        """Test creating a new todo item."""
        response = self.client.post(
            "/todos/",
            json={
                "title": "Buy groceries",
                "description": "Milk, eggs, bread",
                "priority": 3
            },
            headers=self.headers
        )
        assert response.status_code == 201
        data = response.json()
        assert data["title"] == "Buy groceries"
        assert data["completed"] == False
        assert data["owner_id"] == self.test_user["id"]
    
    def test_get_todos_empty(self):
        """Test getting todos when none exist."""
        response = self.client.get("/todos/", headers=self.headers)
        assert response.status_code == 200
        assert response.json() == []
    
    def test_get_todos_with_data(self):
        """Test getting todos after creating some."""
        # Create multiple todos
        todos_data = [
            {"title": "Task 1", "priority": 1},
            {"title": "Task 2", "priority": 2},
            {"title": "Task 3", "priority": 3}
        ]
        for todo in todos_data:
            self.client.post("/todos/", json=todo, headers=self.headers)
        
        response = self.client.get("/todos/", headers=self.headers)
        assert response.status_code == 200
        todos = response.json()
        assert len(todos) == 3
        # Verify ordering by priority
        assert todos[0]["priority"] <= todos[1]["priority"] <= todos[2]["priority"]
    
    def test_update_todo(self):
        """Test updating an existing todo."""
        # Create a todo first
        create_resp = self.client.post(
            "/todos/",
            json={"title": "Original Title", "priority": 1},
            headers=self.headers
        )
        todo_id = create_resp.json()["id"]
        
        # Update it
        update_resp = self.client.put(
            f"/todos/{todo_id}",
            json={
                "title": "Updated Title",
                "completed": True,
                "priority": 5
            },
            headers=self.headers
        )
        assert update_resp.status_code == 200
        updated = update_resp.json()
        assert updated["title"] == "Updated Title"
        assert updated["completed"] == True
    
    def test_delete_todo(self):
        """Test deleting a todo."""
        # Create a todo
        create_resp = self.client.post(
            "/todos/",
            json={"title": "To Delete", "priority": 1},
            headers=self.headers
        )
        todo_id = create_resp.json()["id"]
        
        # Delete it
        delete_resp = self.client.delete(f"/todos/{todo_id}", headers=self.headers)
        assert delete_resp.status_code == 204
        
        # Verify it's gone
        get_resp = self.client.get("/todos/", headers=self.headers)
        assert len(get_resp.json()) == 0
    
    def test_todo_not_found(self):
        """Test accessing a non-existent todo."""
        response = self.client.get("/todos/99999", headers=self.headers)
        assert response.status_code == 404
        assert "not found" in response.json()["detail"].lower()
    
    def test_unauthorized_access(self):
        """Test that endpoints require authentication."""
        response = self.client.get("/todos/")
        assert response.status_code == 401
        assert "not authenticated" in response.json()["detail"].lower()
    
    def test_todo_ownership(self):
        """Test that users can only access their own todos."""
        # Create a todo as testuser
        create_resp = self.client.post(
            "/todos/",
            json={"title": "Private Todo", "priority": 1},
            headers=self.headers
        )
        todo_id = create_resp.json()["id"]
        
        # Try to access as different user
        # Create second user
        self.client.post(
            "/register",
            json={
                "username": "otheruser",
                "email": "other@example.com",
                "password": "OtherPass123!"
            }
        )
        other_token = self.client.post(
            "/token",
            data={"username": "otheruser", "password": "OtherPass123!"}
        ).json()["access_token"]
        
        other_headers = {"Authorization": f"Bearer {other_token}"}
        response = self.client.get(f"/todos/{todo_id}", headers=other_headers)
        assert response.status_code == 403
        assert "not authorized" in response.json()["detail"].lower()

Class-based testing offers several advantages:

  • Shared state: The setup_method fixture runs before every test, providing a fresh test user and authentication token.
  • Helper methods: Private methods like _create_test_user and _get_token reduce code duplication.
  • Logical grouping: All todo-related tests are contained in one class, making the test suite more organized.
  • Clear test flow: Each test method follows a clear arrange-act-assert pattern, making tests easy to read and maintain.

Note that class-based tests still use pytest fixtures and can be run with the same commands as function-based tests. The autouse=True parameter ensures the setup runs automatically for each test method.

Hands-On Practice Task

Now it’s time to apply what you’ve learned. Complete the following practice task to build your testing skills:

Task: Build a Test Suite for a Book Library API

You have a FastAPI application that manages a book library with the following endpoints:

  • POST /books/ – Add a new book (requires: title, author, isbn, published_year)
  • GET /books/ – List all books (supports query parameters: author, year)
  • GET /books/{book_id} – Get a specific book
  • PUT /books/{book_id} – Update a book
  • DELETE /books/{book_id} – Delete a book
  • POST /books/{book_id}/checkout – Check out a book (requires authentication)
  • POST /books/{book_id}/return – Return a checked-out book

Requirements:

  1. Create a test file test_book_library.py with at least 10 test functions
  2. Include tests for:
    • Successful book creation with valid data
    • Book creation with missing required fields (should return 422)
    • Book creation with duplicate ISBN (should return 400)
    • Listing books with no filters (should return all)
    • Listing books filtered by author
    • Listing books filtered by year
    • Getting a specific book by ID
    • Getting a non-existent book (should return 404)
    • Updating a book with valid data
    • Deleting a book
    • Checking out a book (requires authentication)
    • Returning a checked-out book
    • Checking out an already checked-out book (should return 400)
  3. Use a test database with SQLite for isolation
  4. Implement proper fixtures for database setup and teardown
  5. Test both success and error scenarios
  6. Include at least one parametrized test

Starter Code:

# test_book_library.py - Starter template
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.database import get_db, Base
from app.models import Book, User

# Your code here:
# 1. Setup test database
# 2. Override dependencies
# 3. Create fixtures
# 4. Write test functions

def test_create_book_success():
    """Test creating a book with valid data."""
    pass  # Implement this

def test_create_book_missing_fields():
    """Test that missing fields return validation error."""
    pass  # Implement this

def test_create_book_duplicate_isbn():
    """Test that duplicate ISBN returns error."""
    pass  # Implement this

# Continue with remaining tests...

@pytest.mark.parametrize("author,expected_count", [
    ("J.K. Rowling", 2),
    ("George Orwell", 1),
    ("Unknown Author", 0),
])
def test_list_books_by_author(author, expected_count):
    """Test filtering books by author."""
    pass  # Implement this

Expected Output:

When you run your tests with pytest -v test_book_library.py, you should see output similar to:

test_book_library.py::test_create_book_success PASSED
test_book_library.py::test_create_book_missing_fields PASSED
test_book_library.py::test_create_book_duplicate_isbn PASSED
test_book_library.py::test_list_books_no_filter PASSED
test_book_library.py::test_list_books_by_author[George Orwell-1] PASSED
test_book_library.py::test_list_books_by_author[Unknown Author-0] PASSED
test_book_library.py::test_get_book_by_id PASSED
test_book_library.py::test_get_nonexistent_book PASSED
test_book_library.py::test_update_book PASSED
test_book_library.py::test_delete_book PASSED
test_book_library.py::test_checkout_book PASSED
test_book_library.py::test_return_book PASSED
test_book_library.py::test_checkout_already_checked_out PASSED

============================= 13 passed in 2.34s =============================

Bonus Challenge:

Add tests for:

  • Rate limiting (if implemented)
  • Pagination (if the API supports it)
  • Sorting books by different fields
  • Batch operations (if available)

Common Interview Questions

Testing is a frequent topic in FastAPI interviews. Here are common questions you might encounter, along with detailed answers:

Q1: How do you test FastAPI endpoints that require authentication?

Answer: You need to simulate the authentication flow in your tests. The most common approach is to create a test user, obtain an authentication token (JWT or session cookie), and include it in the request headers. Here’s an example:

def test_authenticated_endpoint():
    # Create test user
    client.post("/register", json={"username": "test", "password": "pass"})
    
    # Get token
    token_resp = client.post("/token", data={"username": "test", "password": "pass"})
    token = token_resp.json()["access_token"]
    
    # Use token in subsequent requests
    headers = {"Authorization": f"Bearer {token}"}
    response = client.get("/protected-endpoint", headers=headers)
    assert response.status_code == 200

Q2: How do you handle database dependencies in tests?

Answer: FastAPI’s dependency injection system makes this straightforward. Override the database dependency with a test database using app.dependency_overrides. Use SQLite for testing (it’s fast and requires no setup), and create/drop tables for each test to ensure isolation. Always use fixtures to manage the database lifecycle.

Q3: What’s the difference between unit tests and integration tests for FastAPI?

Answer: Unit tests focus on testing individual functions or methods in isolation, mocking external dependencies like databases. Integration tests test the full request-response cycle, including the database, middleware, and authentication. For FastAPI, most tests are integration tests using TestClient because they provide more confidence that the API works end-to-end. However, you should still write unit tests for complex business logic that doesn’t depend on the framework.

Q4: How do you test async endpoints?

Answer: FastAPI’s TestClient handles async endpoints automatically in most cases. However, if you need to test async functions directly, use pytest-asyncio:

@pytest.mark.asyncio
async def test_async_function():
    result = await my_async_function()
    assert result == expected_value

Q5: How do you test file uploads?

Answer: Use the TestClient’s files parameter to simulate file uploads:

def test_file_upload():
    file_content = b"test file content"
    response = client.post(
        "/upload",
        files={"file": ("test.txt", file_content, "text/plain")}
    )
    assert response.status_code == 200

Q6: How do you test WebSocket endpoints?

Answer: Use the TestClient’s WebSocket context manager:

def test_websocket():
    with client.websocket_connect("/ws") as websocket:
        websocket.send_text("Hello")
        data = websocket.receive_text()
        assert data == "Hello"

Q7: What is test coverage and why is it important?

Answer: Test coverage measures what percentage of your code is executed during tests. While 100% coverage doesn’t guarantee bug-free code, it helps identify untested code paths. Use tools like pytest-cov to measure coverage:

pytest --cov=app test_book_library.py

Q8: How do you handle external API calls in tests?

Answer: Mock external API calls using libraries like unittest.mock or httpx-mock. This prevents tests from depending on external services and makes them faster and more reliable:

from unittest.mock import patch

def test_external_api_call():
    with patch("app.services.external_api.get_data") as mock_get:
        mock_get.return_value = {"key": "value"}
        response = client.get("/endpoint-that-calls-external-api")
        assert response.status_code == 200

Q9: How do you test error handling in FastAPI?

Answer: Test both validation errors (422) and custom HTTP exceptions. For validation errors, send invalid data and check the response structure. For custom exceptions, trigger the error condition and verify the status code and error message:

def test_custom_exception():
    response = client.get("/items/0")  # Assuming item 0 doesn't exist
    assert response.status_code == 404
    assert response.json()["detail"] == "Item not found"

Q10: What are best practices for organizing test files in a FastAPI project?

Answer: Follow these best practices:

  • Place tests in a tests/ directory at the project root
  • Mirror the application structure (e.g., tests/test_routers/test_users.py)
  • Use descriptive test function names that explain what’s being tested
  • Group related tests using classes or separate test files
  • Use conftest.py for shared fixtures
  • Keep tests independent and idempotent
  • Run tests with pytest -v --tb=short for clear output

Mastering these testing concepts will make you a more effective FastAPI developer and prepare you for technical interviews. Remember that good tests are an investment that pays off through fewer bugs and faster development cycles.

Leave a Reply

Your email address will not be published. Required fields are marked *