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 tests verify business logic in isolation, without external dependencies.
# 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# 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")
)
)# 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 tests verify that adapters (repositories, external services) work correctly with their dependencies (database, APIs).
# 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()# 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) == 2Test 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 == 409tests/
├── 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
- 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
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# ✅ 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")- Each test should be independent
- Use fixtures for setup/teardown
- Don't share state between 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 -lFile: 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/CDUsing 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"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-missingAll 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 NoneFixtures 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()class MockProductRepository(IProductRepository):
"""Minimal mock implementation for testing."""
async def find_by_id(self, product_id: int) -> Product | None:
# Simplified mock behavior
return Nonefrom 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()# 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")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✅ 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.