Writing more testable code with dependency injection

Jordan Edmunds, PhD
4 min readApr 25, 2024

--

The architect’s favorite software pattern

Software engineer injecting a dependency — courtesy of GPT-4 ❤

Often times, the code we write will need to interact with the outside world. Maybe it has to make an API call, write to a database, or perform some system-level operations.

But, as good software engineers, we want to make sure that our code can still be tested, and tested in environments which may not have access to these external dependencies.

Enter dependency injection.

With this pattern, we can write clean, testable code while still being able to interact with whatever external dependencies we like.

Suppose we’re writing an application called MyApplication, and as part of that application we want to notify our users of something via e-mail:

class MyApplication:
def __init__(self) -> None:
pass

def send_notification(self, user, content) -> None:
send_email(user, content)

Ideally, we would like to test this application without having to set up a full e-mail server and query that server. To do this, we can refactor our code to “inject” the e-mail sending functionality as a dependency.

Defining your dependency’s interface

First, we decide what the interface of our dependency should look like. In other words, what should it do, and how should it be called? Defining interfaces is the most important job of the software engineer, as the interfaces to your code determine how your user interacts with that code and what surface area you have to test it.

I’m going to call our notification sender NotificationSender and give it a .send(user, content) method, which returns None .

class NotificationSender(abc.ABC):
def send(self, user, content) -> None:
pass

Injecting the dependency

Now that we’ve decided what our interface to this dependency should look like, we can change our application to use this dependency and have it injected when the application is created:

class MyApplication:
def __init__(self, sender: NotificationSender) -> None:
self._sender = notification_sender

def send_notification(self, user, content) -> None:
self._sender.send(user, content)

Notice that at this point — when writing the application, you don’t actually need to have written a concrete implementation for the NotificationSender interface — your application will dutifully call the .send() method of whatever implementation you pass in.

Implementing the dependency

However, to actually test and run the application, we need to define concrete NotificationSender implementations. Here I’ll define one we can use for test purposes called TestSender and one we will use in production, called EmailSender :

class TestSender(NotificationSender):
def __init__(self):
self.sent = []

def send(self, user, content):
self.sent.append((user, content))

class EmailSender(NotificationSender):
def send(self, user, content):
send_email(user, content)

Now, in production our application will look like this:

if __name__ == "__main__":
sender = EmailSender()
application = MyApplication(sender=sender)
application.send_notification("Bob", "Hello world")

And in our unit tests, we can now test the functionality of our application:

def test_my_app_sends_notifications():
sender = TestSender()
application = MyApplication(sender=sender)
application.send("Bob", "Hello world")
assert ("Bob", "Hello world") in sender.sent

Isolating external code

In addition to allowing us to inject “mock” dependencies into our application to verify that it works as expected, we also get another testability benefit using dependency injection: all our external calls are isolated in a single module.

This means that if we want to test e-mail functionality, we only need to test the EmailSender class. We do not need to test the full application to validate our external calls will work — although we probably should at some point in an end-to-end integration test.

This means that our test harnesses become dramatically simpler: rather than having to include an SMTP server in every single one of our application-level unit tests, we only include it in (1) the unit tests for the EmailSender class and (2) our full-blown integration tests. Most of our application tests can use the TestSender class instead.

Same exact code path

One significant benefit of using dependency injection, as opposed to having some type of switching logic inside your application is that when you test with your “mock” dependency injected your application goes through the same exact logic as it would in production.

The only execption to this is after it enters your injected module. In practice, this means you can be very confident that tests with your mocked dependency will serve as tests of production code — as long as the dependency you inject in production is itself tested and conforms to the same interface.

Bonus Points — Flexibility

In the next article, we’ll go over this example to show how we also get benefits for the flexibility in our code — the ability to choose or change how notifications are delivered to users at runtime.

This is part of my series on dependency injection. For the next story in the series, see Writing Future-Proof code with dependency injection.

--

--

Jordan Edmunds, PhD

Software Engineer who writes about #software, #diversity, #health, and random musings. Reformed academic, Berkeley EECS PhD. Autistic self-advocate. He/Him.