Skip to content

Latest commit

 

History

History
749 lines (576 loc) · 21.6 KB

File metadata and controls

749 lines (576 loc) · 21.6 KB

Testing Strategy & Best Practices

Testing Philosophy

This blueprint follows a test pyramid strategy:

        /\
       /  \      E2E Tests (few)
      /----\
     /      \    Integration Tests (some)
    /--------\
   /          \  Unit Tests (many)
  /____________\
  • Unit Tests (80%): Fast, isolated, focus on business logic
  • Integration Tests (15%): Test adapters, repositories, database
  • E2E Tests (5%): Full system tests, manual or automated via HTTP client

Unit Testing: Domain & Application Layers

Unit tests verify business logic in isolation, without external dependencies.

Testing Domain Entities

# tests/unit/domain/test_product_entity.py
import pytest
from src.domain.entities.product import Product


class TestProductEntity:
    """Test Product entity business rules."""
    
    def test_product_creation_with_valid_data(self):
        """Test creating a product with valid data."""
        product = Product(
            id=1,
            name="Laptop",
            description="High-performance laptop",
            price=999.99
        )
        
        assert product.id == 1
        assert product.name == "Laptop"
    
    def test_product_creation_with_negative_price_fails(self):
        """Test that negative prices are rejected."""
        with pytest.raises(ValueError, match="price cannot be negative"):
            Product(
                id=1,
                name="Invalid Product",
                description="Test",
                price=-100.0
            )
    
    def test_product_creation_with_empty_name_fails(self):
        """Test that empty names are rejected."""
        with pytest.raises(ValueError, match="name cannot be empty"):
            Product(
                id=1,
                name="",
                description="Test",
                price=100.0
            )
    
    def test_product_price_update(self):
        """Test updating product price."""
        product = Product(id=1, name="Laptop", description="Test", price=999.99)
        
        product.update_price(799.99)
        
        assert product.price == 799.99
    
    def test_product_price_update_with_negative_value_fails(self):
        """Test that price updates reject negative values."""
        product = Product(id=1, name="Laptop", description="Test", price=999.99)
        
        with pytest.raises(ValueError, match="price cannot be negative"):
            product.update_price(-100.0)
    
    def test_product_equality_by_id(self):
        """Test that products are equal if they have the same ID."""
        product1 = Product(id=1, name="Laptop", description="Test", price=999.99)
        product2 = Product(id=1, name="Different", description="Test", price=100.0)
        
        assert product1 == product2  # Same ID = equal
    
    def test_product_inequality_by_id(self):
        """Test that products are not equal if they have different IDs."""
        product1 = Product(id=1, name="Laptop", description="Test", price=999.99)
        product2 = Product(id=2, name="Laptop", description="Test", price=999.99)
        
        assert product1 != product2  # Different IDs = not equal

Testing Use Cases with Mocks

# tests/unit/application/test_product_use_cases.py
import pytest
from decimal import Decimal
from unittest.mock import AsyncMock

from src.application.use_cases.product_use_cases import CreateProductUseCase
from src.domain.entities.product import Product
from src.domain.exceptions.product_exceptions import ProductAlreadyExistsError


class MockProductRepository:
    """Mock repository for testing use cases."""
    
    def __init__(self):
        self.products = {}
        self.next_id = 1
    
    async def find_by_name(self, name: str) -> Product | None:
        for product in self.products.values():
            if product.name == name:
                return product
        return None
    
    async def save(self, product: Product) -> Product:
        if product.id is None:
            product.id = self.next_id
            self.next_id += 1
        self.products[product.id] = product
        return product


@pytest.mark.asyncio
class TestCreateProductUseCase:
    """Test CreateProductUseCase with mock repository."""
    
    async def test_create_product_successfully(self):
        """Test successful product creation."""
        # Arrange
        repository = MockProductRepository()
        use_case = CreateProductUseCase(repository)
        
        # Act
        from src.application.dto.product_dto import CreateProductInputDTO
        result = await use_case.execute(
            CreateProductInputDTO(
                name="Laptop",
                description="High-performance",
                price=Decimal("999.99")
            )
        )
        
        # Assert
        assert result.id is not None
        assert result.name == "Laptop"
        assert result.price == Decimal("999.99")
    
    async def test_create_product_with_duplicate_name_raises_error(self):
        """Test that duplicate names are rejected."""
        # Arrange
        repository = MockProductRepository()
        use_case = CreateProductUseCase(repository)
        
        from src.application.dto.product_dto import CreateProductInputDTO
        input_dto = CreateProductInputDTO(
            name="Laptop",
            description="High-performance",
            price=Decimal("999.99")
        )
        
        # Create first product
        await use_case.execute(input_dto)
        
        # Act & Assert: Creating duplicate should fail
        with pytest.raises(ProductAlreadyExistsError):
            await use_case.execute(input_dto)
    
    async def test_create_product_with_invalid_price(self):
        """Test that invalid prices are handled."""
        repository = MockProductRepository()
        use_case = CreateProductUseCase(repository)
        
        from src.application.dto.product_dto import CreateProductInputDTO
        with pytest.raises(ValueError):
            await use_case.execute(
                CreateProductInputDTO(
                    name="Laptop",
                    description="Test",
                    price=Decimal("-100.00")
                )
            )

Testing Domain Exceptions

# tests/unit/domain/test_product_exceptions.py
import pytest
from src.domain.exceptions.product_exceptions import (
    ProductNotFoundError,
    ProductAlreadyExistsError,
)


class TestProductExceptions:
    """Test domain exception messages."""
    
    def test_product_not_found_error_message(self):
        """Test ProductNotFoundError message."""
        error = ProductNotFoundError(product_id=123)
        
        assert "123" in str(error)
        assert "not found" in str(error).lower()
    
    def test_product_already_exists_error_message(self):
        """Test ProductAlreadyExistsError message."""
        error = ProductAlreadyExistsError(name="Laptop")
        
        assert "Laptop" in str(error)
        assert "already exists" in str(error).lower()

Integration Testing: Infrastructure Layer

Integration tests verify that adapters (repositories, external services) work correctly with their dependencies (database, APIs).

Setting Up Test Database

# tests/conftest.py
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker

from src.infrastructure.database.base import Base
from src.infrastructure.database.session import AsyncSessionLocal


@pytest_asyncio.fixture
async def test_db_engine():
    """Create an in-memory SQLite database for testing."""
    # Use in-memory SQLite for fast tests
    engine = create_async_engine(
        "sqlite+aiosqlite:///:memory:",
        echo=False,  # Set to True for SQL debugging
    )
    
    # Create all tables
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    
    yield engine
    
    # Cleanup
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)
    
    await engine.dispose()


@pytest_asyncio.fixture
async def test_db_session(test_db_engine):
    """Create a test database session."""
    async_session = async_sessionmaker(
        test_db_engine,
        class_=AsyncSession,
        expire_on_commit=False,
    )
    
    async with async_session() as session:
        yield session
        await session.rollback()

Testing Repository Implementations

# tests/integration/repositories/test_product_repository.py
import pytest
from decimal import Decimal
from sqlalchemy.ext.asyncio import AsyncSession

from src.domain.entities.product import Product
from src.infrastructure.repositories.sqlalchemy_product_repository import (
    SQLAlchemyProductRepository,
)


@pytest.mark.asyncio
class TestSQLAlchemyProductRepository:
    """Integration tests for SQLAlchemy product repository."""
    
    async def test_save_new_product(self, test_db_session: AsyncSession):
        """Test saving a new product."""
        # Arrange
        repository = SQLAlchemyProductRepository(test_db_session)
        product = Product(
            id=None,
            name="Laptop",
            description="High-performance",
            price=Decimal("999.99")
        )
        
        # Act
        saved = await repository.save(product)
        
        # Assert
        assert saved.id is not None
        assert saved.name == "Laptop"
    
    async def test_find_by_id(self, test_db_session: AsyncSession):
        """Test finding a product by ID."""
        # Arrange
        repository = SQLAlchemyProductRepository(test_db_session)
        product = Product(id=None, name="Laptop", description="Test", price=Decimal("999.99"))
        saved = await repository.save(product)
        
        # Act
        found = await repository.find_by_id(saved.id)
        
        # Assert
        assert found is not None
        assert found.name == "Laptop"
    
    async def test_find_by_nonexistent_id_returns_none(self, test_db_session: AsyncSession):
        """Test that finding non-existent product returns None."""
        repository = SQLAlchemyProductRepository(test_db_session)
        
        found = await repository.find_by_id(999)
        
        assert found is None
    
    async def test_find_by_name(self, test_db_session: AsyncSession):
        """Test finding a product by name."""
        repository = SQLAlchemyProductRepository(test_db_session)
        product = Product(id=None, name="Laptop", description="Test", price=Decimal("999.99"))
        await repository.save(product)
        
        found = await repository.find_by_name("Laptop")
        
        assert found is not None
        assert found.name == "Laptop"
    
    async def test_delete_product(self, test_db_session: AsyncSession):
        """Test deleting a product."""
        repository = SQLAlchemyProductRepository(test_db_session)
        product = Product(id=None, name="Laptop", description="Test", price=Decimal("999.99"))
        saved = await repository.save(product)
        
        deleted = await repository.delete(saved.id)
        
        assert deleted is True
        assert await repository.find_by_id(saved.id) is None
    
    async def test_find_all_with_pagination(self, test_db_session: AsyncSession):
        """Test finding all products with pagination."""
        repository = SQLAlchemyProductRepository(test_db_session)
        
        # Create multiple products
        for i in range(5):
            product = Product(
                id=None,
                name=f"Product {i}",
                description="Test",
                price=Decimal(str(100 + i))
            )
            await repository.save(product)
        
        # Test pagination
        page1 = await repository.find_all(skip=0, limit=2)
        page2 = await repository.find_all(skip=2, limit=2)
        
        assert len(page1) == 2
        assert len(page2) == 2

API Integration Tests (HTTP Client)

Test full HTTP endpoints using FastAPI's test client.

# tests/integration/api/test_product_endpoints.py
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession

from src.main import app


@pytest.mark.asyncio
class TestProductEndpoints:
    """Integration tests for product API endpoints."""
    
    async def test_create_product_endpoint(self):
        """Test POST /api/products/ endpoint."""
        async with AsyncClient(app=app, base_url="http://test") as client:
            response = await client.post(
                "/api/products/",
                json={
                    "name": "Test Laptop",
                    "description": "High-performance laptop",
                    "price": "1299.99"
                }
            )
            
            assert response.status_code == 201
            data = response.json()
            assert data["name"] == "Test Laptop"
            assert float(data["price"]) == 1299.99
            assert "id" in data
    
    async def test_create_product_with_invalid_data_returns_422(self):
        """Test that invalid data returns 422 Unprocessable Entity."""
        async with AsyncClient(app=app, base_url="http://test") as client:
            response = await client.post(
                "/api/products/",
                json={
                    "name": "",  # Empty name invalid
                    "description": "Test",
                    "price": "100.00"
                }
            )
            
            assert response.status_code == 422
    
    async def test_get_product_endpoint(self):
        """Test GET /api/products/{id} endpoint."""
        async with AsyncClient(app=app, base_url="http://test") as client:
            # Create a product first
            create_response = await client.post(
                "/api/products/",
                json={
                    "name": "Get Test Product",
                    "description": "For testing GET",
                    "price": "99.99"
                }
            )
            product_id = create_response.json()["id"]
            
            # Get the product
            response = await client.get(f"/api/products/{product_id}")
            
            assert response.status_code == 200
            assert response.json()["name"] == "Get Test Product"
    
    async def test_get_nonexistent_product_returns_404(self):
        """Test that getting non-existent product returns 404."""
        async with AsyncClient(app=app, base_url="http://test") as client:
            response = await client.get("/api/products/99999")
            
            assert response.status_code == 404
    
    async def test_list_products_endpoint(self):
        """Test GET /api/products/ endpoint."""
        async with AsyncClient(app=app, base_url="http://test") as client:
            response = await client.get("/api/products/?skip=0&limit=10")
            
            assert response.status_code == 200
            data = response.json()
            assert "items" in data
            assert "total" in data
            assert "skip" in data
            assert "limit" in data
    
    async def test_create_duplicate_product_returns_409(self):
        """Test that creating duplicate product returns 409 Conflict."""
        async with AsyncClient(app=app, base_url="http://test") as client:
            product_data = {
                "name": "Unique Product",
                "description": "First creation",
                "price": "100.00"
            }
            
            # Create product
            await client.post("/api/products/", json=product_data)
            
            # Try to create duplicate
            response = await client.post("/api/products/", json=product_data)
            
            assert response.status_code == 409

Test Organization Best Practices

1. Test File Structure

tests/
├── conftest.py                      # Shared fixtures
├── unit/
│   ├── domain/
│   │   ├── test_product_entity.py
│   │   └── test_product_exceptions.py
│   └── application/
│       └── test_product_use_cases.py
└── integration/
    ├── repositories/
    │   └── test_product_repository.py
    └── api/
        └── test_product_endpoints.py

2. Naming Conventions

  • Test files: test_*.py
  • Test classes: Test<FeatureName>
  • Test methods: test_<behavior>_<scenario>_<outcome>

Examples:

  • test_create_product_with_duplicate_name_raises_error
  • test_find_by_id_returns_product_when_exists
  • test_product
  • test_create

3. Test Structure: Arrange-Act-Assert

async def test_something(fixture):
    # Arrange: Set up test data and dependencies
    product = Product(...)
    repository = MockRepository()
    
    # Act: Execute the behavior being tested
    result = await repository.save(product)
    
    # Assert: Verify the result
    assert result.id is not None

4. Use Descriptive Assertions

# ✅ Good: Clear what's being tested
assert product.price == Decimal("99.99"), "Price should match input"

# ❌ Bad: Unclear failure message
assert product.price == Decimal("99.99")

5. Test Isolation

  • Each test should be independent
  • Use fixtures for setup/teardown
  • Don't share state between tests

Running Tests

# Run all tests
pytest

# Run with verbose output
pytest -v

# Run specific test file
pytest tests/unit/application/test_product_use_cases.py

# Run tests matching pattern
pytest -k "test_create"

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

# Run tests in parallel (faster)
pytest -n auto

# Run with detailed output on failures
pytest -vv --tb=long

# Run only slow tests
pytest -m slow

# Stop at first failure
pytest -x

# Show local variables in tracebacks
pytest -l

Pytest Configuration

File: pytest.ini

[pytest]
# Use asyncio for async tests
asyncio_mode = auto

# Test discovery patterns
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*

# Markers for organizing tests
markers =
    unit: Unit tests (business logic in isolation)
    integration: Integration tests (with database/external services)
    slow: Slow running tests
    skip_ci: Tests to skip in CI/CD

Using markers:

# tests/integration/api/test_product_endpoints.py
import pytest

@pytest.mark.integration
@pytest.mark.asyncio
async def test_product_endpoint():
    pass

# Run only integration tests
# pytest -m integration

# Run everything except slow tests
# pytest -m "not slow"

Coverage Report

Generate coverage report:

# Terminal coverage report
pytest --cov=src --cov-report=term

# HTML coverage report (open htmlcov/index.html)
pytest --cov=src --cov-report=html

# Show lines not covered
pytest --cov=src --cov-report=term-missing

Testing Async Code

All async tests use @pytest.mark.asyncio:

@pytest.mark.asyncio
async def test_async_operation():
    """Test an async function."""
    result = await some_async_function()
    assert result is not None

Fixtures for async code:

import pytest_asyncio

@pytest_asyncio.fixture
async def test_db_session():
    """Async fixture for database session."""
    async with AsyncSessionLocal() as session:
        yield session
        await session.rollback()

Mocking Best Practices

1. Mock Repository for Use Case Tests

class MockProductRepository(IProductRepository):
    """Minimal mock implementation for testing."""
    
    async def find_by_id(self, product_id: int) -> Product | None:
        # Simplified mock behavior
        return None

2. Using unittest.mock for Spying

from unittest.mock import AsyncMock

@pytest.mark.asyncio
async def test_repository_called(mock_repository):
    """Verify repository method was called."""
    mock_repository.save = AsyncMock()
    
    await use_case.execute(input_dto)
    
    mock_repository.save.assert_called_once()

Common Testing Patterns

Test Data Factories

# tests/factories.py
from decimal import Decimal
from src.domain.entities.product import Product


class ProductFactory:
    """Factory for creating test products."""
    
    @staticmethod
    def create(
        id: int | None = None,
        name: str = "Test Product",
        price: Decimal = Decimal("99.99"),
        **kwargs
    ) -> Product:
        """Create a product with defaults."""
        return Product(
            id=id,
            name=name,
            description=kwargs.get("description", "Test description"),
            price=price,
        )

# Usage in tests
def test_something():
    product = ProductFactory.create(name="Laptop", price=Decimal("1299.99"))
    assert product.price > Decimal("1000")

Parameterized Tests

import pytest

@pytest.mark.parametrize("price,should_fail", [
    (Decimal("100.00"), False),
    (Decimal("0.00"), False),
    (Decimal("-10.00"), True),
])
def test_product_price_validation(price, should_fail):
    """Test various price values."""
    if should_fail:
        with pytest.raises(ValueError):
            Product(id=1, name="Test", description="Test", price=price)
    else:
        product = Product(id=1, name="Test", description="Test", price=price)
        assert product.price == price

Summary

Unit Tests: Test business logic with mocks (80% of tests)
Integration Tests: Test with real database/APIs (15% of tests)
E2E Tests: Manual or automated full system tests (5% of tests)
Clear Naming: Descriptive test names
AAA Pattern: Arrange-Act-Assert
Isolation: Independent, non-flaky tests
Coverage: Aim for 80%+ code coverage


Next: Read through the example test files in tests/ directory to understand the patterns better.