Skip to content

Latest commit

 

History

History
810 lines (638 loc) · 23.6 KB

File metadata and controls

810 lines (638 loc) · 23.6 KB

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.