Skip to main content

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:

  1. It actually sends emails when tested
  2. You need a real SMTP server
  3. Tests are slow and unreliable
  4. 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

  1. Testability - Inject test doubles for fast, reliable tests
  2. Flexibility - Easy to swap implementations
  3. Decoupling - Classes don't depend on concrete implementations
  4. 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!