Dependency Injection
In this chapter, we'll learn about dependency injection (DI) and how it makes code more testable. We'll cover:
- What is dependency injection?
- Why DI matters for testing
- Constructor injection vs method injection
- Using protocols (abstract base classes) for interfaces
- Testing with injected dependencies
The problem with hard-coded dependencies
Consider this code that sends notifications:
import smtplib
class NotificationService:
def send(self, message, recipient):
# Hard-coded dependency on email
server = smtplib.SMTP('smtp.gmail.com', 587)
server.send_message(message, to_addrs=[recipient])
server.quit()
This code is hard to test because:
- It actually sends emails when tested
- You need a real SMTP server
- Tests are slow and unreliable
- You can't verify what was sent without checking your inbox
The solution: Dependency Injection
Instead of creating dependencies inside a class, we inject them from outside.
Write the test first
from notification import NotificationService
class FakeEmailSender:
def __init__(self):
self.sent_messages = []
def send(self, message, recipient):
self.sent_messages.append((message, recipient))
def test_notification_sends_email():
fake_sender = FakeEmailSender()
service = NotificationService(email_sender=fake_sender)
service.send("Hello!", "user@example.com")
assert len(fake_sender.sent_messages) == 1
message, recipient = fake_sender.sent_messages[0]
assert message == "Hello!"
assert recipient == "user@example.com"
We inject a FakeEmailSender that records what was sent, instead of actually sending emails.
Make it pass
class NotificationService:
def __init__(self, email_sender):
self.email_sender = email_sender
def send(self, message, recipient):
self.email_sender.send(message, recipient)
The NotificationService no longer creates its own email sender - it receives one through its constructor.
Defining interfaces with protocols
Python's Protocol (from typing) lets you define interfaces:
from typing import Protocol
class EmailSender(Protocol):
def send(self, message: str, recipient: str) -> None:
"""Send a message to a recipient."""
...
Any class with a matching send method automatically satisfies this protocol.
Real implementation
import smtplib
class SmtpEmailSender:
def __init__(self, host: str, port: int):
self.host = host
self.port = port
def send(self, message: str, recipient: str) -> None:
server = smtplib.SMTP(self.host, self.port)
server.send_message(message, to_addrs=[recipient])
server.quit()
Test implementation
class FakeEmailSender:
def __init__(self):
self.sent_messages = []
def send(self, message: str, recipient: str) -> None:
self.sent_messages.append((message, recipient))
Both satisfy the EmailSender protocol, so both can be used with NotificationService.
Constructor injection
The most common form of DI - pass dependencies through the constructor:
class OrderProcessor:
def __init__(
self,
payment_gateway,
inventory_service,
notification_service
):
self.payment = payment_gateway
self.inventory = inventory_service
self.notifications = notification_service
def process(self, order):
self.payment.charge(order.total)
self.inventory.reserve(order.items)
self.notifications.send(
f"Order {order.id} confirmed",
order.customer_email
)
Testing with constructor injection
def test_order_processing():
fake_payment = FakePaymentGateway()
fake_inventory = FakeInventoryService()
fake_notifications = FakeNotificationService()
processor = OrderProcessor(
payment_gateway=fake_payment,
inventory_service=fake_inventory,
notification_service=fake_notifications
)
order = Order(id=1, total=100, items=["item1"])
processor.process(order)
assert fake_payment.charged == 100
assert fake_inventory.reserved == ["item1"]
assert len(fake_notifications.sent) == 1
Method injection
Sometimes you only need a dependency for a single method:
def format_report(data, formatter):
"""Format a report using the provided formatter."""
return formatter.format(data)
Testing method injection
class FakeFormatter:
def format(self, data):
return f"FORMATTED: {data}"
def test_format_report():
fake_formatter = FakeFormatter()
result = format_report({"key": "value"}, fake_formatter)
assert result == "FORMATTED: {'key': 'value'}"
Default dependencies
You can provide default implementations while still allowing injection:
class NotificationService:
def __init__(self, email_sender=None):
if email_sender is None:
from email_senders import SmtpEmailSender
email_sender = SmtpEmailSender("smtp.gmail.com", 587)
self.email_sender = email_sender
Or use a factory function:
def create_notification_service(email_sender=None):
if email_sender is None:
email_sender = SmtpEmailSender("smtp.gmail.com", 587)
return NotificationService(email_sender)
A complete example: Weather service
Let's build a weather service that fetches data from an API.
Define the protocol
from typing import Protocol
class WeatherAPI(Protocol):
def get_temperature(self, city: str) -> float:
"""Get the current temperature for a city."""
...
Write tests first
import pytest
from weather import WeatherService
class FakeWeatherAPI:
def __init__(self):
self.temperatures = {}
def set_temperature(self, city, temp):
self.temperatures[city] = temp
def get_temperature(self, city):
if city not in self.temperatures:
raise ValueError(f"Unknown city: {city}")
return self.temperatures[city]
def test_get_weather_report():
fake_api = FakeWeatherAPI()
fake_api.set_temperature("London", 15.5)
service = WeatherService(fake_api)
report = service.get_report("London")
assert report == "The temperature in London is 15.5°C"
def test_get_weather_unknown_city():
fake_api = FakeWeatherAPI()
service = WeatherService(fake_api)
with pytest.raises(ValueError, match="Unknown city"):
service.get_report("Atlantis")
Implementation
class WeatherService:
def __init__(self, api):
self.api = api
def get_report(self, city: str) -> str:
temp = self.api.get_temperature(city)
return f"The temperature in {city} is {temp}°C"
Real API implementation
import requests
class OpenWeatherAPI:
def __init__(self, api_key):
self.api_key = api_key
self.base_url = "https://api.openweathermap.org/data/2.5"
def get_temperature(self, city):
response = requests.get(
f"{self.base_url}/weather",
params={"q": city, "appid": self.api_key, "units": "metric"}
)
response.raise_for_status()
return response.json()["main"]["temp"]
Benefits of dependency injection
1. Testability
You can inject test doubles (fakes, stubs, mocks) instead of real implementations:
# In tests
service = WeatherService(FakeWeatherAPI())
# In production
service = WeatherService(OpenWeatherAPI(api_key))
2. Flexibility
Easy to swap implementations:
# Use different APIs based on config
if config.use_mock:
api = MockWeatherAPI()
elif config.provider == "openweather":
api = OpenWeatherAPI(config.api_key)
else:
api = AccuWeatherAPI(config.api_key)
service = WeatherService(api)
3. Single Responsibility
Classes focus on their core logic, not on creating dependencies:
# Good: WeatherService just uses an API
class WeatherService:
def __init__(self, api):
self.api = api
# Bad: WeatherService creates its own API
class WeatherService:
def __init__(self):
self.api = OpenWeatherAPI(os.environ["API_KEY"])
Testing patterns with DI
Spy pattern
Record calls for verification:
class SpyEmailSender:
def __init__(self):
self.calls = []
def send(self, message, recipient):
self.calls.append({"message": message, "recipient": recipient})
def test_sends_to_correct_recipient():
spy = SpyEmailSender()
service = NotificationService(spy)
service.send("Hello", "alice@example.com")
assert spy.calls[0]["recipient"] == "alice@example.com"
Stub pattern
Return predefined values:
class StubWeatherAPI:
def get_temperature(self, city):
return 20.0 # Always returns 20
def test_with_stub():
stub = StubWeatherAPI()
service = WeatherService(stub)
assert service.get_report("AnyCity") == "The temperature in AnyCity is 20.0°C"
Fake pattern
Working implementation, but simplified:
class FakeDatabase:
def __init__(self):
self._data = {}
def save(self, key, value):
self._data[key] = value
def get(self, key):
return self._data.get(key)
Wrapping up
We've covered:
- Dependency Injection - Pass dependencies instead of creating them
- Constructor injection - Pass dependencies through
__init__ - Method injection - Pass dependencies to individual methods
- Protocols - Define interfaces with
typing.Protocol - Test doubles - Fakes, stubs, and spies for testing
Key benefits
- Testability - Inject test doubles for fast, reliable tests
- Flexibility - Easy to swap implementations
- Decoupling - Classes don't depend on concrete implementations
- Single Responsibility - Classes focus on their core logic
The TDD connection
DI and TDD work together beautifully:
- TDD encourages writing testable code
- DI makes code more testable
- Tests drive you toward clean, decoupled designs
Start with a test, inject your dependencies, and you'll naturally write cleaner code!