Introduction
Testing is fundamental to software quality. A well-tested codebase enables confident refactoring, catches bugs early, and serves as documentation. This guide covers testing strategies, test types, and best practices.
Test Pyramid
The test pyramid guides test distribution: many fast unit tests at the base, fewer integration tests in the middle, and few slow E2E tests at the top.
# tests/test_user_service.py
import pytest
from unittest.mock import Mock, patch
from datetime import datetime
class TestUserService:
"""Unit tests for user service."""
@pytest.fixture
def user_repo(self):
return Mock()
@pytest.fixture
def email_service(self):
return Mock()
@pytest.fixture
def service(self, user_repo, email_service):
from services.user_service import UserService
return UserService(user_repo, email_service)
def test_create_user_success(self, service, user_repo, email_service):
"""Test successful user creation."""
user_repo.find_by_email.return_value = None
result = service.create_user(
email="[email protected]",
name="Test User",
password="secure123"
)
assert result.email == "[email protected]"
assert result.name == "Test User"
assert result.id is not None
user_repo.save.assert_called_once()
email_service.send_welcome.assert_called_once()
def test_create_user_duplicate_email(self, service, user_repo):
"""Test user creation with existing email."""
user_repo.find_by_email.return_value = Mock(id="existing")
with pytest.raises(ValueError, match="already exists"):
service.create_user(
email="[email protected]",
name="Test",
password="password123"
)
def test_create_user_invalid_email(self, service):
"""Test user creation with invalid email."""
with pytest.raises(ValueError, match="valid email"):
service.create_user(
email="invalid",
name="Test",
password="password123"
)
# tests/test_api.py
from fastapi.testclient import TestClient
class TestUserAPI:
"""Integration tests for user API."""
@pytest.fixture
def client(self, test_db):
from main import app
from dependencies import get_db
from database import get_engine
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=get_engine())
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as client:
yield client
app.dependency_overrides.clear()
def test_create_and_get_user(self, client):
"""Test creating and retrieving a user."""
# Create user
response = client.post(
"/api/v1/users",
json={
"email": "[email protected]",
"name": "API Test",
"password": "password123"
}
)
assert response.status_code == 201
user_id = response.json()["id"]
# Get user
response = client.get(f"/api/v1/users/{user_id}")
assert response.status_code == 200
assert response.json()["email"] == "[email protected]"
Mocking and Fixtures
# tests/conftest.py
import pytest
from unittest.mock import Mock, MagicMock
from datetime import datetime, timedelta
@pytest.fixture
def mock_user():
"""Create a mock user object."""
user = Mock()
user.id = "test-id"
user.email = "[email protected]"
user.name = "Test User"
user.is_active = True
user.created_at = datetime.utcnow()
return user
@pytest.fixture
def mock_db_session():
"""Create a mock database session."""
session = MagicMock()
session.query.return_value.filter.return_value.first.return_value = None
return session
@pytest.fixture
def sample_users():
"""Create sample users for testing."""
return [
{"id": "1", "email": "[email protected]", "name": "User 1"},
{"id": "2", "email": "[email protected]", "name": "User 2"},
{"id": "3", "email": "[email protected]", "name": "User 3"},
]
class TestMockingPatterns:
"""Examples of common mocking patterns."""
def test_method_call_verification(self, mock_user):
"""Verify a method was called."""
service = Mock()
service.create_user.return_value = mock_user
result = service.create_user("[email protected]", "Test")
service.create_user.assert_called_once_with("[email protected]", "Test")
assert result.email == "[email protected]"
def test_return_value_side_effect(self, mock_user):
"""Use side effect for complex behavior."""
service = Mock()
service.get_user.side_effect = [
None, # First call returns None
mock_user, # Second call returns user
]
assert service.get_user("1") is None
assert service.get_user("2").email == "[email protected]"
def test_property_mock(self):
"""Mock properties and attributes."""
mock = Mock()
mock.user.name = "Test"
mock.user.email = "[email protected]"
assert mock.user.name == "Test"
Conclusion
Effective testing requires strategy: write many fast unit tests, some integration tests, and few E2E tests. Mock external dependencies. Use fixtures for setup. Test behavior, not implementation. Aim for meaningful coverage, not arbitrary numbers.
Resources
- pytest Documentation
- “Working Effectively with Legacy Code” by Michael Feathers
- Test-Driven Development by Kent Beck
Comments