Clean Pytest
Clean, maintainable pytest test patterns using Fake-based testing, contract testing, and dependency injection. Focuses on test isolation, reusability, and clarity through explicit AAA pattern and well-structured fixtures.
When to Use
- Setting up test suites for Python/MCP projects
- Creating Fake implementations for external dependencies
- Writing contract tests for MCP tools/controllers
- Implementing test patterns with dependency injection
- Testing layered architectures (Controllers → Services → Repositories)
- Writing parametrized tests for multiple scenarios
Core Principles
1. Fakes over Mocks
Use Fake classes instead of mocking with unittest.mock. Fakes are in-memory implementations that mimic real dependencies without external calls.
Why Fakes?
- More readable and maintainable
- Easier to debug
- Better test isolation
- No monkey-patching magic
- Self-documenting behavior
2. Explicit AAA Pattern
Structure every test into three clear phases with comments:
# Arrange
# Set up test data and dependencies
# Act
# Execute the code under test
# Assert
# Verify the result
3. Dependency Injection in Fixtures
Inject dependencies between fixtures to maintain relationships and avoid duplication.
4. Contract Testing
Verify that components register tools/functions correctly and pass expected arguments.
Architecture Pattern
Controller (MCP Tools)
↓
Service (Business Logic)
↓
Repository (Data Access)
↓
Fake (Test Implementation)
Creating Fakes
Basic Fake Structure
Create a Fake class that implements the same interface as the real dependency:
# tests/fakes.py
from typing import Any, Dict, List, Optional
class FakeAuth:
"""Fake implementation of AuthProvider for testing."""
def __init__(self) -> None:
self.created: List[Dict[str, Any]] = []
self.deleted: List[str] = []
self._seq = 0
self.fail_on_create: bool = False
def create_user(self, email: str, password: str, display_name: str) -> str:
if self.fail_on_create:
raise RuntimeError("create_user failed (fake)")
self._seq += 1
uid = f"uid-{self._seq}"
rec = {"uid": uid, "email": email, "display_name": display_name}
self.created.append(rec)
return uid
def delete_user(self, uid: str) -> None:
self.deleted.append(uid)
Repository Fake
class FakeUsersRepo:
"""Fake implementation of UsersRepository."""
def __init__(self) -> None:
self.users: Dict[str, Dict[str, Any]] = {}
self.fail_on_upsert: bool = False
def upsert_user_doc(self, uid: str, data: Dict[str, Any]) -> None:
if self.fail_on_upsert:
raise RuntimeError("upsert_user_doc failed (fake)")
self.users[uid] = dict(data)
def list_users(self, limit: Optional[int] = None) -> List[Dict[str, Any]]:
items = list(self.users.values())
if limit and limit > 0:
items = items[:limit]
return [dict(it) for it in items]
Controlled Failure Fakes
class FakeAuth:
def __init__(self) -> None:
self.fail_on_create: bool = False # Control failure in tests
def create_user(self, email: str, password: str, display_name: str) -> str:
if self.fail_on_create:
raise RuntimeError("create_user failed (fake)")
# ... rest of implementation
Nested Repository Fakes
class FakeSectorsRepo:
def __init__(self, institutions: FakeInstitutionsRepo | None = None) -> None:
self.institutions = institutions # Inject dependency
self.data: Dict[str, Dict[str, Dict[str, Any]]] = {}
def institution_exists(self, institution_id: str) -> bool:
return bool(self.institutions and institution_id in self.institutions.data)
def upsert_sector(self, institution_id: str, sector_id: str, data: Dict[str, Any]) -> None:
self.data.setdefault(institution_id, {})[sector_id] = dict(data)
Fixtures
Basic Fixture (conftest.py)
# tests/conftest.py
import pytest
from tests.fakes import FakeAuth, FakeUsersRepo
@pytest.fixture()
def fake_auth():
"""Provide a fresh FakeAuth for each test."""
return FakeAuth()
@pytest.fixture()
def fake_users_repo():
"""Provide a fresh FakeUsersRepo for each test."""
return FakeUsersRepo()
Fixture with Dependency Injection
@pytest.fixture()
def fake_sectors_repo(fake_institutions_repo):
"""FakeSectorsRepo depends on FakeInstitutionsRepo."""
return FakeSectorsRepo(institutions=fake_institutions_repo)
@pytest.fixture()
def fake_rooms_repo(fake_sectors_repo):
"""FakeRoomsRepo depends on FakeSectorsRepo."""
return FakeRoomsRepo(sectors=fake_sectors_repo)
Environment Fixture
@pytest.fixture()
def user_env(fake_auth, fake_users_repo):
"""Provide service and all dependencies for user operations."""
from myapp.services.user_service import UserService
svc = UserService(fake_auth, fake_users_repo)
return svc, fake_auth, fake_users_repo
Seeded Environment Fixture
@pytest.fixture()
def user_env_seeded(user_env):
"""Environment with pre-seeded data."""
svc, auth, repo = user_env
svc.add_user(email="test@example.com", password="secret", name="Test User")
return svc
Fixture with Cleanup
@pytest.fixture()
def temp_file():
"""Provide a temporary file and clean up after test."""
import tempfile
import os
fd, path = tempfile.mkstemp()
os.close(fd)
yield path
os.unlink(path)
Service Layer Testing
Basic AAA Pattern Test
# tests/test_user_service.py
import pytest
from myapp.services.user_service import UserService
def test_add_user_success(fake_auth, fake_users_repo):
# Arrange
svc = UserService(fake_auth, fake_users_repo)
email = "test@example.com"
password = "secret"
name = "Test User"
# Act
result = svc.add_user(email=email, password=password, name=name)
# Assert
assert result["status"] == "ok"
assert result["user"]["email"] == email
assert result["user"]["name"] == name
assert result["uid"] in fake_users_repo.users
Parametrized Tests
@pytest.mark.parametrize(
"email,password,name,role",
[
("a@example.com", "secret", "Alice", "admin"),
("b@example.com", "p@ss", "Bob", "user"),
],
)
def test_add_user_parametrized(user_env, email, password, name, role):
svc, _auth, _repo = user_env
# Act
res = svc.add_user(email=email, password=password, name=name, global_role=role)
# Assert
assert res["status"] == "ok"
assert res["user"]["email"] == email
assert res["user"]["name"] == name
assert res["user"]["globalRole"] == role
Testing Error Scenarios with Fakes
@pytest.mark.parametrize("email", ["c@example.com", "d@example.com"])
def test_add_user_rollback_on_firestore_failure(fake_auth, fake_users_repo, email):
# Arrange
fake_users_repo.fail_on_upsert = True
svc = UserService(fake_auth, fake_users_repo)
# Act & Assert
with pytest.raises(RuntimeError):
svc.add_user(email=email, password="secret", name="Bob")
# Assert rollback
assert fake_auth.deleted, "Expected auth user to be deleted on Firestore failure"
Testing Timestamp Normalization
def test_list_users_normalizes_timestamps_to_iso(user_env):
# Arrange
svc, _auth, repo = user_env
from datetime import datetime
repo.users["u1"] = {
"id": "u1",
"email": "x@y.z",
"name": "X",
"globalRole": "user",
"createdAt": datetime(2024, 1, 1),
"updatedAt": datetime(2024, 1, 2),
}
# Act
res = svc.list_users(limit=10)
# Assert
assert res["status"] == "ok"
assert res["count"] == 1
user = res["users"][0]
assert isinstance(user["createdAt"], str)
assert isinstance(user["updatedAt"], str)
Contract Testing
MCP Tool Registration Contract
Test that controllers properly register tools with expected signatures:
# tests/test_controllers_contract.py
from typing import Any, Callable, Dict
class FakeMCP:
"""Minimal FakeMCP for contract testing."""
def __init__(self) -> None:
self.tools: Dict[str, Callable[..., Any]] = {}
self.meta: Dict[str, Dict[str, Any]] = {}
def tool(self, name: str, description: str, tags: Optional[set] = None, meta: Optional[dict] = None):
def decorator(fn: Callable[..., Any]):
self.tools[name] = fn
self.meta[name] = {
"description": description,
"tags": set(tags or set()),
"meta": dict(meta or {}),
}
return fn
return decorator
class FakeUserService:
"""Simple fake service that records calls."""
def __init__(self):
self.calls = []
def add_user(self, **kwargs):
self.calls.append(("add_user", kwargs))
return {"status": "ok", "op": "add_user", "args": kwargs}
def test_users_controller_contract():
# Arrange
from myapp.controllers.users_controller import UsersController
fake = FakeMCP()
svc = FakeUserService()
UsersController(fake, svc)
# Assert tool registration
assert "add_user" in fake.tools
assert "list_users" in fake.tools
# Act & Assert tool behavior
res = fake.tools["add_user"](
email="a@x.y", password="s3cr3t", name="Alice", global_role="admin"
)
assert res["status"] == "ok"
assert res["op"] == "add_user"
assert res["args"]["email"] == "a@x.y"
Parametrized Contract Tests
@pytest.mark.parametrize(
"email,password,name,role",
[
("a@x.y", "s3cr3t", "Alice", "admin"),
("b@x.y", "p@ssw0rd", "Bob", "user"),
],
)
def test_users_add_user_parametrized(_users_env, email, password, name, role):
# Arrange
fake, _ = _users_env
# Act
res = fake.tools["add_user"](
email=email, password=password, name=name, global_role=role
)
# Assert
assert res["status"] == "ok"
assert res["op"] == "add_user"
assert res["args"]["email"] == email
Repository Layer Testing
Testing Repository Operations
@pytest.fixture()
def repo_env(fake_institutions_repo, fake_sectors_repo):
# Seed data
fake_institutions_repo.upsert("inst1", {"id": "inst1", "name": "Inst One"})
fake_sectors_repo.upsert_sector(
"inst1", "er", {"id": "er", "name": "ER", "slug": "er", "isActive": True}
)
return fake_sectors_repo
Testing Multiple Data Scenarios
@pytest.mark.parametrize("rooms", [
["101"],
["201", {"name": "102", "id": "room-102"}],
])
def test_add_and_list_rooms(room_env, rooms):
svc, _ = room_env
# Act
res = svc.add_sector_rooms("inst1", "er", rooms)
# Assert
assert res["status"] == "ok"
assert res["count"] == len(rooms)
lst = svc.list_sector_rooms("inst1", "er", limit=10)
assert lst["status"] == "ok"
assert lst["count"] == len(rooms)
Testing Limit Behavior
@pytest.mark.parametrize("limit", [1, 3])
def test_list_rooms_limits(room_env_seeded, limit):
svc = room_env_seeded
# Act
lst = svc.list_sector_rooms("inst1", "er", limit=limit)
# Assert
assert lst["status"] == "ok"
assert lst["count"] == min(2, limit) # 2 items seeded
Testing Not Found Scenarios
@pytest.mark.parametrize("room_id,deleted", [
("room-102", True),
("room-999", False),
])
def test_remove_rooms_parametrized(room_env_seeded, room_id, deleted):
svc = room_env_seeded
# Act
res = svc.remove_sector_room("inst1", "er", room_id)
# Assert
assert res["deleted"] is deleted
if not deleted:
assert res.get("reason") == "room_not_found"
Integration Testing
Conditional Integration Tests
Skip integration tests when external dependencies are not available:
# tests/test_integration_wiring.py
import os
import pytest
# Gate this integration test on presence of credentials
_ENV_KEYS = (
"FIREBASE_SERVICE_ACCOUNT",
"GOOGLE_APPLICATION_CREDENTIALS",
)
_has_env_creds = any(os.getenv(k) for k in _ENV_KEYS)
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not _has_env_creds,
reason=(
"Integration test requires Firebase Admin credentials via env "
"(FIREBASE_SERVICE_ACCOUNT or GOOGLE_APPLICATION_CREDENTIALS)"
),
),
]
@pytest.mark.integration
def test_build_app_initializes_and_registers_tools():
# Arrange
from myapp.wiring import build_app
# Act
app = build_app()
# Assert
assert hasattr(app, "run")
Test Isolation
Each test should be independent and not share state:
def test_user_created_in_one_test_not_visible_in_another(fake_auth, fake_users_repo):
# Arrange
svc1 = UserService(fake_auth, fake_users_repo)
# Act
result1 = svc1.add_user(email="test1@example.com", password="secret", name="User1")
# Assert - second test with fresh fixtures should not see this user
svc2 = UserService(fake_auth, fake_users_repo)
users = svc2.list_users()
assert users["count"] == 1 # Only the user from this test
Testing Anti-Patterns to Avoid
Don't Mock What You Don't Own
❌ Bad - Mocking external library:
@patch('firebase_admin.auth.create_user')
def test_add_user(mock_create_user):
mock_create_user.return_value = Mock(uid="uid-1")
# ... test code
✅ Good - Use Fake for your interface:
def test_add_user(fake_auth, fake_users_repo):
svc = UserService(fake_auth, fake_users_repo)
# ... test code
Don't Test Implementation Details
❌ Bad - Testing internal method calls:
def test_add_user(fake_auth, fake_users_repo):
svc = UserService(fake_auth, fake_users_repo)
svc.add_user(email="test@example.com", password="secret", name="User")
assert fake_auth.created == [{"uid": "uid-1", ...}] # Implementation detail
✅ Good - Testing observable behavior:
def test_add_user(fake_auth, fake_users_repo):
svc = UserService(fake_auth, fake_users_repo)
result = svc.add_user(email="test@example.com", password="secret", name="User")
assert result["status"] == "ok"
assert result["user"]["email"] == "test@example.com"
Don't Skip Error Paths
❌ Bad - Only happy path:
def test_add_user_success(fake_auth, fake_users_repo):
# Only tests success case
✅ Good - Test all scenarios:
def test_add_user_success(fake_auth, fake_users_repo):
# Happy path
def test_add_user_rollback_on_firestore_failure(fake_auth, fake_users_repo):
# Error path
def test_add_user_handles_duplicate_email(fake_auth, fake_users_repo):
# Edge case
Running Tests
# Run all tests
pytest
# Run with coverage
pytest --cov=myapp --cov-report=term-missing
# Run specific test file
pytest tests/test_user_service.py
# Run specific test
pytest tests/test_user_service.py::test_add_user_success
# Run parametrized tests with verbose output
pytest -v tests/test_user_service.py::test_add_user_parametrized
# Skip integration tests
pytest -m "not integration"
# Run only integration tests
pytest -m integration
# Stop on first failure
pytest -x
# Show local variables on failure
pytest -l
# Run tests in parallel (with pytest-xdist)
pytest -n auto
Best Practices Checklist
- Use Fake classes instead of
unittest.mock - Structure tests with explicit AAA comments
- Use fixtures for test setup
- Inject dependencies between fixtures
- Parametrize tests for multiple scenarios
- Test happy paths and error paths
- Test edge cases and boundaries
- Write contract tests for interfaces
- Ensure test isolation
- Use descriptive test names
- Keep tests focused on one behavior
- Avoid testing implementation details
- Test at appropriate level (unit vs integration)
- Mock external dependencies appropriately
- Maintain test coverage