Skip to content

Testing Guide

This guide covers comprehensive testing strategies for the FastAPI boilerplate, including unit tests, integration tests, and API testing.

Test Setup

Testing Dependencies

The boilerplate uses these testing libraries:

  • pytest - Testing framework
  • pytest-asyncio - Async test support
  • httpx - Async HTTP client for API tests
  • pytest-cov - Coverage reporting
  • faker - Test data generation

Test Configuration

pytest.ini

[tool:pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = 
    -v
    --strict-markers
    --strict-config
    --cov=src
    --cov-report=term-missing
    --cov-report=html
    --cov-report=xml
    --cov-fail-under=80
markers =
    unit: Unit tests
    integration: Integration tests
    api: API tests
    slow: Slow tests
asyncio_mode = auto

Test Database Setup

Create tests/conftest.py:

import asyncio
import pytest
import pytest_asyncio
from typing import AsyncGenerator
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from faker import Faker

from src.app.core.config import settings
from src.app.core.db.database import Base, async_get_db
from src.app.main import app
from src.app.models.user import User
from src.app.models.post import Post
from src.app.core.security import get_password_hash

# Test database configuration
TEST_DATABASE_URL = "postgresql+asyncpg://test_user:test_pass@localhost:5432/test_db"

# Create test engine and session
test_engine = create_async_engine(TEST_DATABASE_URL, echo=False)
TestSessionLocal = sessionmaker(
    test_engine, class_=AsyncSession, expire_on_commit=False
)

fake = Faker()


@pytest_asyncio.fixture
async def async_session() -> AsyncGenerator[AsyncSession, None]:
    """Create a fresh database session for each test."""
    async with test_engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

    async with TestSessionLocal() as session:
        yield session

    async with test_engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)


@pytest_asyncio.fixture
async def async_client(async_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
    """Create an async HTTP client for testing."""
    def get_test_db():
        return async_session

    app.dependency_overrides[async_get_db] = get_test_db

    async with AsyncClient(app=app, base_url="http://test") as client:
        yield client

    app.dependency_overrides.clear()


@pytest_asyncio.fixture
async def test_user(async_session: AsyncSession) -> User:
    """Create a test user."""
    user = User(
        name=fake.name(),
        username=fake.user_name(),
        email=fake.email(),
        hashed_password=get_password_hash("testpassword123"),
        is_superuser=False
    )
    async_session.add(user)
    await async_session.commit()
    await async_session.refresh(user)
    return user


@pytest_asyncio.fixture
async def test_superuser(async_session: AsyncSession) -> User:
    """Create a test superuser."""
    user = User(
        name="Super Admin",
        username="superadmin",
        email="admin@test.com",
        hashed_password=get_password_hash("superpassword123"),
        is_superuser=True
    )
    async_session.add(user)
    await async_session.commit()
    await async_session.refresh(user)
    return user


@pytest_asyncio.fixture
async def test_post(async_session: AsyncSession, test_user: User) -> Post:
    """Create a test post."""
    post = Post(
        title=fake.sentence(),
        content=fake.text(),
        created_by_user_id=test_user.id
    )
    async_session.add(post)
    await async_session.commit()
    await async_session.refresh(post)
    return post


@pytest_asyncio.fixture
async def auth_headers(async_client: AsyncClient, test_user: User) -> dict:
    """Get authentication headers for a test user."""
    login_data = {
        "username": test_user.username,
        "password": "testpassword123"
    }

    response = await async_client.post("/api/v1/auth/login", data=login_data)
    token = response.json()["access_token"]

    return {"Authorization": f"Bearer {token}"}


@pytest_asyncio.fixture
async def superuser_headers(async_client: AsyncClient, test_superuser: User) -> dict:
    """Get authentication headers for a test superuser."""
    login_data = {
        "username": test_superuser.username,
        "password": "superpassword123"
    }

    response = await async_client.post("/api/v1/auth/login", data=login_data)
    token = response.json()["access_token"]

    return {"Authorization": f"Bearer {token}"}

Unit Tests

Model Tests

# tests/test_models.py
import pytest
from datetime import datetime
from src.app.models.user import User
from src.app.models.post import Post


@pytest.mark.unit
class TestUserModel:
    """Test User model functionality."""

    async def test_user_creation(self, async_session):
        """Test creating a user."""
        user = User(
            name="Test User",
            username="testuser",
            email="test@example.com",
            hashed_password="hashed_password"
        )

        async_session.add(user)
        await async_session.commit()
        await async_session.refresh(user)

        assert user.id is not None
        assert user.name == "Test User"
        assert user.username == "testuser"
        assert user.email == "test@example.com"
        assert user.created_at is not None
        assert user.is_superuser is False
        assert user.is_deleted is False

    async def test_user_relationships(self, async_session, test_user):
        """Test user relationships."""
        post = Post(
            title="Test Post",
            content="Test content",
            created_by_user_id=test_user.id
        )

        async_session.add(post)
        await async_session.commit()

        # Test relationship
        await async_session.refresh(test_user)
        assert len(test_user.posts) == 1
        assert test_user.posts[0].title == "Test Post"


@pytest.mark.unit
class TestPostModel:
    """Test Post model functionality."""

    async def test_post_creation(self, async_session, test_user):
        """Test creating a post."""
        post = Post(
            title="Test Post",
            content="This is test content",
            created_by_user_id=test_user.id
        )

        async_session.add(post)
        await async_session.commit()
        await async_session.refresh(post)

        assert post.id is not None
        assert post.title == "Test Post"
        assert post.content == "This is test content"
        assert post.created_by_user_id == test_user.id
        assert post.created_at is not None
        assert post.is_deleted is False

Schema Tests

# tests/test_schemas.py
import pytest
from pydantic import ValidationError
from src.app.schemas.user import UserCreate, UserRead, UserUpdate
from src.app.schemas.post import PostCreate, PostRead, PostUpdate


@pytest.mark.unit
class TestUserSchemas:
    """Test User schema validation."""

    def test_user_create_valid(self):
        """Test valid user creation schema."""
        user_data = {
            "name": "John Doe",
            "username": "johndoe",
            "email": "john@example.com",
            "password": "SecurePass123!"
        }

        user = UserCreate(**user_data)
        assert user.name == "John Doe"
        assert user.username == "johndoe"
        assert user.email == "john@example.com"
        assert user.password == "SecurePass123!"

    def test_user_create_invalid_email(self):
        """Test invalid email validation."""
        with pytest.raises(ValidationError) as exc_info:
            UserCreate(
                name="John Doe",
                username="johndoe",
                email="invalid-email",
                password="SecurePass123!"
            )

        errors = exc_info.value.errors()
        assert any(error['type'] == 'value_error' for error in errors)

    def test_user_create_short_password(self):
        """Test password length validation."""
        with pytest.raises(ValidationError) as exc_info:
            UserCreate(
                name="John Doe",
                username="johndoe",
                email="john@example.com",
                password="123"
            )

        errors = exc_info.value.errors()
        assert any(error['type'] == 'value_error' for error in errors)

    def test_user_update_partial(self):
        """Test partial user update."""
        update_data = {"name": "Jane Doe"}
        user_update = UserUpdate(**update_data)

        assert user_update.name == "Jane Doe"
        assert user_update.username is None
        assert user_update.email is None


@pytest.mark.unit
class TestPostSchemas:
    """Test Post schema validation."""

    def test_post_create_valid(self):
        """Test valid post creation."""
        post_data = {
            "title": "Test Post",
            "content": "This is a test post content"
        }

        post = PostCreate(**post_data)
        assert post.title == "Test Post"
        assert post.content == "This is a test post content"

    def test_post_create_empty_title(self):
        """Test empty title validation."""
        with pytest.raises(ValidationError):
            PostCreate(
                title="",
                content="This is a test post content"
            )

    def test_post_create_long_title(self):
        """Test title length validation."""
        with pytest.raises(ValidationError):
            PostCreate(
                title="x" * 101,  # Exceeds max length
                content="This is a test post content"
            )

CRUD Tests

# tests/test_crud.py
import pytest
from src.app.crud.crud_users import crud_users
from src.app.crud.crud_posts import crud_posts
from src.app.schemas.user import UserCreate, UserUpdate
from src.app.schemas.post import PostCreate, PostUpdate


@pytest.mark.unit
class TestUserCRUD:
    """Test User CRUD operations."""

    async def test_create_user(self, async_session):
        """Test creating a user."""
        user_data = UserCreate(
            name="CRUD User",
            username="cruduser",
            email="crud@example.com",
            password="password123"
        )

        user = await crud_users.create(db=async_session, object=user_data)
        assert user["name"] == "CRUD User"
        assert user["username"] == "cruduser"
        assert user["email"] == "crud@example.com"
        assert "id" in user

    async def test_get_user(self, async_session, test_user):
        """Test getting a user."""
        retrieved_user = await crud_users.get(
            db=async_session, 
            id=test_user.id
        )

        assert retrieved_user is not None
        assert retrieved_user["id"] == test_user.id
        assert retrieved_user["name"] == test_user.name
        assert retrieved_user["username"] == test_user.username

    async def test_get_user_by_email(self, async_session, test_user):
        """Test getting a user by email."""
        retrieved_user = await crud_users.get(
            db=async_session,
            email=test_user.email
        )

        assert retrieved_user is not None
        assert retrieved_user["email"] == test_user.email

    async def test_update_user(self, async_session, test_user):
        """Test updating a user."""
        update_data = UserUpdate(name="Updated Name")

        updated_user = await crud_users.update(
            db=async_session,
            object=update_data,
            id=test_user.id
        )

        assert updated_user["name"] == "Updated Name"
        assert updated_user["id"] == test_user.id

    async def test_delete_user(self, async_session, test_user):
        """Test soft deleting a user."""
        await crud_users.delete(db=async_session, id=test_user.id)

        # User should be soft deleted
        deleted_user = await crud_users.get(
            db=async_session,
            id=test_user.id,
            is_deleted=True
        )

        assert deleted_user is not None
        assert deleted_user["is_deleted"] is True

    async def test_get_multi_users(self, async_session):
        """Test getting multiple users."""
        # Create multiple users
        for i in range(5):
            user_data = UserCreate(
                name=f"User {i}",
                username=f"user{i}",
                email=f"user{i}@example.com",
                password="password123"
            )
            await crud_users.create(db=async_session, object=user_data)

        # Get users with pagination
        result = await crud_users.get_multi(
            db=async_session,
            offset=0,
            limit=3
        )

        assert len(result["data"]) == 3
        assert result["total_count"] == 5
        assert result["has_more"] is True


@pytest.mark.unit
class TestPostCRUD:
    """Test Post CRUD operations."""

    async def test_create_post(self, async_session, test_user):
        """Test creating a post."""
        post_data = PostCreate(
            title="Test Post",
            content="This is test content"
        )

        post = await crud_posts.create(
            db=async_session,
            object=post_data,
            created_by_user_id=test_user.id
        )

        assert post["title"] == "Test Post"
        assert post["content"] == "This is test content"
        assert post["created_by_user_id"] == test_user.id

    async def test_get_posts_by_user(self, async_session, test_user):
        """Test getting posts by user."""
        # Create multiple posts
        for i in range(3):
            post_data = PostCreate(
                title=f"Post {i}",
                content=f"Content {i}"
            )
            await crud_posts.create(
                db=async_session,
                object=post_data,
                created_by_user_id=test_user.id
            )

        # Get posts by user
        result = await crud_posts.get_multi(
            db=async_session,
            created_by_user_id=test_user.id
        )

        assert len(result["data"]) == 3
        assert result["total_count"] == 3

Integration Tests

API Endpoint Tests

# tests/test_api_users.py
import pytest
from httpx import AsyncClient


@pytest.mark.integration
class TestUserAPI:
    """Test User API endpoints."""

    async def test_create_user(self, async_client: AsyncClient):
        """Test user creation endpoint."""
        user_data = {
            "name": "New User",
            "username": "newuser",
            "email": "new@example.com",
            "password": "SecurePass123!"
        }

        response = await async_client.post("/api/v1/users", json=user_data)
        assert response.status_code == 201

        data = response.json()
        assert data["name"] == "New User"
        assert data["username"] == "newuser"
        assert data["email"] == "new@example.com"
        assert "hashed_password" not in data
        assert "id" in data

    async def test_create_user_duplicate_email(self, async_client: AsyncClient, test_user):
        """Test creating user with duplicate email."""
        user_data = {
            "name": "Duplicate User",
            "username": "duplicateuser",
            "email": test_user.email,  # Use existing email
            "password": "SecurePass123!"
        }

        response = await async_client.post("/api/v1/users", json=user_data)
        assert response.status_code == 409  # Conflict

    async def test_get_users(self, async_client: AsyncClient):
        """Test getting users list."""
        response = await async_client.get("/api/v1/users")
        assert response.status_code == 200

        data = response.json()
        assert "data" in data
        assert "total_count" in data
        assert "has_more" in data
        assert isinstance(data["data"], list)

    async def test_get_user_by_id(self, async_client: AsyncClient, test_user):
        """Test getting specific user."""
        response = await async_client.get(f"/api/v1/users/{test_user.id}")
        assert response.status_code == 200

        data = response.json()
        assert data["id"] == test_user.id
        assert data["name"] == test_user.name
        assert data["username"] == test_user.username

    async def test_get_user_not_found(self, async_client: AsyncClient):
        """Test getting non-existent user."""
        response = await async_client.get("/api/v1/users/99999")
        assert response.status_code == 404

    async def test_update_user_authorized(self, async_client: AsyncClient, test_user, auth_headers):
        """Test updating user with proper authorization."""
        update_data = {"name": "Updated Name"}

        response = await async_client.patch(
            f"/api/v1/users/{test_user.id}",
            json=update_data,
            headers=auth_headers
        )
        assert response.status_code == 200

        data = response.json()
        assert data["name"] == "Updated Name"
        assert data["id"] == test_user.id

    async def test_update_user_unauthorized(self, async_client: AsyncClient, test_user):
        """Test updating user without authorization."""
        update_data = {"name": "Updated Name"}

        response = await async_client.patch(
            f"/api/v1/users/{test_user.id}",
            json=update_data
        )
        assert response.status_code == 401

    async def test_delete_user_superuser(self, async_client: AsyncClient, test_user, superuser_headers):
        """Test deleting user as superuser."""
        response = await async_client.delete(
            f"/api/v1/users/{test_user.id}",
            headers=superuser_headers
        )
        assert response.status_code == 200

    async def test_delete_user_forbidden(self, async_client: AsyncClient, test_user, auth_headers):
        """Test deleting user without superuser privileges."""
        response = await async_client.delete(
            f"/api/v1/users/{test_user.id}",
            headers=auth_headers
        )
        assert response.status_code == 403


@pytest.mark.integration
class TestAuthAPI:
    """Test Authentication API endpoints."""

    async def test_login_success(self, async_client: AsyncClient, test_user):
        """Test successful login."""
        login_data = {
            "username": test_user.username,
            "password": "testpassword123"
        }

        response = await async_client.post("/api/v1/auth/login", data=login_data)
        assert response.status_code == 200

        data = response.json()
        assert "access_token" in data
        assert "refresh_token" in data
        assert data["token_type"] == "bearer"

    async def test_login_invalid_credentials(self, async_client: AsyncClient, test_user):
        """Test login with invalid credentials."""
        login_data = {
            "username": test_user.username,
            "password": "wrongpassword"
        }

        response = await async_client.post("/api/v1/auth/login", data=login_data)
        assert response.status_code == 401

    async def test_get_current_user(self, async_client: AsyncClient, test_user, auth_headers):
        """Test getting current user information."""
        response = await async_client.get("/api/v1/auth/me", headers=auth_headers)
        assert response.status_code == 200

        data = response.json()
        assert data["id"] == test_user.id
        assert data["username"] == test_user.username

    async def test_refresh_token(self, async_client: AsyncClient, test_user):
        """Test token refresh."""
        # First login to get refresh token
        login_data = {
            "username": test_user.username,
            "password": "testpassword123"
        }

        login_response = await async_client.post("/api/v1/auth/login", data=login_data)
        refresh_token = login_response.json()["refresh_token"]

        # Use refresh token to get new access token
        refresh_response = await async_client.post(
            "/api/v1/auth/refresh",
            headers={"Authorization": f"Bearer {refresh_token}"}
        )

        assert refresh_response.status_code == 200
        data = refresh_response.json()
        assert "access_token" in data

Running Tests

Basic Test Commands

# Run all tests
uv run pytest

# Run specific test categories
uv run pytest -m unit
uv run pytest -m integration
uv run pytest -m api

# Run tests with coverage
uv run pytest --cov=src --cov-report=html

# Run tests in parallel
uv run pytest -n auto

# Run specific test file
uv run pytest tests/test_api_users.py

# Run with verbose output
uv run pytest -v

# Run tests matching pattern
uv run pytest -k "test_user"

# Run tests and stop on first failure
uv run pytest -x

# Run slow tests
uv run pytest -m slow

Test Environment Setup

# Set up test database
createdb test_db

# Run tests with specific environment
ENVIRONMENT=testing uv run pytest

# Run tests with debug output
uv run pytest -s --log-cli-level=DEBUG

Testing Best Practices

Test Organization

  • Separate concerns: Unit tests for business logic, integration tests for API endpoints
  • Use fixtures: Create reusable test data and setup
  • Test isolation: Each test should be independent
  • Clear naming: Test names should describe what they're testing

Test Data

  • Use factories: Create test data programmatically
  • Avoid hardcoded values: Use variables and constants
  • Clean up: Ensure tests don't leave data behind
  • Realistic data: Use faker or similar libraries for realistic test data

Assertions

  • Specific assertions: Test specific behaviors, not just "it works"
  • Multiple assertions: Test all relevant aspects of the response
  • Error cases: Test error conditions and edge cases
  • Performance: Include performance tests for critical paths

Mocking

# Example of mocking external dependencies
from unittest.mock import patch, AsyncMock

@pytest.mark.unit
async def test_external_api_call():
    """Test function that calls external API."""
    with patch('src.app.services.external_api.make_request') as mock_request:
        mock_request.return_value = {"status": "success"}

        result = await some_function_that_calls_external_api()

        assert result["status"] == "success"
        mock_request.assert_called_once()

Continuous Integration

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_USER: test_user
          POSTGRES_PASSWORD: test_pass
          POSTGRES_DB: test_db
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
    - uses: actions/checkout@v3

    - name: Set up Python
      uses: actions/setup-python@v4
      with:
        python-version: 3.11

    - name: Install dependencies
      run: |
        pip install uv
        uv sync

    - name: Run tests
      run: uv run pytest --cov=src --cov-report=xml

    - name: Upload coverage
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml

This testing guide provides comprehensive coverage of testing strategies for the FastAPI boilerplate, ensuring reliable and maintainable code.