Advanced Python Testing with DDD Mocks and Factories
Dependency Injection
Giving Up Control
Most software components don't work in isolation. A data processing class might need a database connection. A web controller might need a user authentication service. When a class creates these necessary objects, or dependencies, for itself, it becomes tightly coupled to them. It knows exactly how to build them and can't function without those specific, hard-coded implementations.
# This class creates its own dependency
class ReportGenerator:
def __init__(self):
# Hard-coded dependency: it MUST use Postgres.
self.database = PostgresDBConnection()
def generate(self):
data = self.database.fetch_data()
# ... logic to generate report from data
This approach is rigid. What if you want to switch to a different database, like MySQL? You'd have to change the ReportGenerator class directly. Worse, how would you test this class without connecting to a real Postgres database? You can't easily swap in a fake database for a unit test.
Dependency Injection, or DI, flips this model on its head. Instead of a component creating its own dependencies, they are given, or injected, from an outside source.
Dependency Injection is a design pattern that allows us to develop loosely coupled code by removing the responsibility of creating and managing dependencies from a class.
This simple change has profound effects. The component no longer needs to know how to construct its dependencies; it only needs to know how to use them. This is the core idea behind the Dependency Inversion Principle, one of the five SOLID principles of object-oriented design.
Injection in Practice
The most common way to implement DI is through a class's constructor. The dependencies are passed in as arguments when the object is created.
# This class receives its dependency
class ReportGenerator:
# The database connection is 'injected' here
def __init__(self, database_connection):
self.database = database_connection
def generate(self):
data = self.database.fetch_data()
# ... logic to generate report
# --- Now we can use different databases ---
# Production code
postgres_db = PostgresDBConnection()
report_generator = ReportGenerator(postgres_db)
# Test code
fake_db = FakeDBConnection() # A mock object for testing
test_generator = ReportGenerator(fake_db)
Look at the difference. The ReportGenerator is now completely decoupled from any specific database implementation. It can work with a Postgres connection, a MySQL connection, or a mock database object used for testing. Its only requirement is that the injected object has a fetch_data method.
This flexibility is the primary benefit of dependency injection, especially for testing. It allows you to test components in isolation by providing them with controlled, predictable dependencies.
DI and Testability
Let's see how DI transforms our ability to write unit tests. Without DI, testing our original ReportGenerator would require a running Postgres database. This makes the test slow, complex, and fragile. It's an integration test, not a unit test.
With DI, we can create a simple mock object that mimics the database connection. This mock can return predictable data without any real database interaction.
import unittest
# A mock database class for our test
class MockDatabase:
def fetch_data(self):
# Return predictable, fake data for the test
return [{"item": "Test Item", "value": 100}]
class TestReportGenerator(unittest.TestCase):
def test_generate_report(self):
# 1. Create the mock dependency
mock_db = MockDatabase()
# 2. Inject the mock into the class under test
report_generator = ReportGenerator(mock_db)
# 3. Run the test
report = report_generator.generate()
# 4. Assert that the report was generated correctly
# using the data from our mock object.
self.assertIn("Test Item", report)
self.assertIn("100", report)
# Assume ReportGenerator is defined as in the previous example
This unit test is fast, self-contained, and reliable. We are testing the logic of ReportGenerator in complete isolation from its dependencies. Modern web frameworks in Python, like FastAPI, have dependency injection built into their core, making it easy to manage dependencies for things like database sessions, authentication, and configuration settings.
Time to check your understanding.
What is the primary problem that Dependency Injection (DI) aims to solve in software design?
How does using Dependency Injection improve the testability of a software component?
By embracing dependency injection, you create code that is more modular, flexible, and, most importantly, far easier to test.