Python Testing Tip #6 / April 10, 2024

Testing behavior, not implementation details – part 3

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.

Share this tip

Get Python Testing Tips in Your Inbox

Practical Python testing advice delivered to your inbox.