One of the properties of a valuable test is that it's fast. Database-backed tests become increasingly slow as test suites grow, making it impractical to run them all locally during development.
Solution: Contract Tests
Contract testing enables using fast test doubles (like in-memory stores) during testing while deploying slower but persistent implementations (like SQLite) in production.
How Contract Tests Work
Both implementations must expose identical interfaces. A contract class (e.g., TaskStoreContract) defines shared test behaviors without implementing specific fixtures. Individual test classes then implement only their specific fixtures while inheriting all contract tests, ensuring consistent behavior across implementations.
class TaskStoreContract:
# Contract tests for any TaskStore implementation.
def test_can_add_and_retrieve_task(self, store):
task = Task(id=1, title="Test task", done=False)
store.add(task)
assert store.get(1) == task
def test_returns_none_for_nonexistent_task(self, store):
assert store.get(999) is None
class TestInMemoryTaskStore(TaskStoreContract):
@pytest.fixture
def store(self):
return InMemoryTaskStore()
class TestSQLiteTaskStore(TaskStoreContract):
@pytest.fixture
def store(self, tmp_path):
return SQLiteTaskStore(tmp_path / "test.db")
Benefits
The approach guarantees observable behavior consistency between different implementations, allowing developers to swap them based on needs. This forces better encapsulation by hiding implementation details, which improves code quality overall.
This article concludes the three-part series on testing behavior rather than implementation specifics.