Dependency Injection in Python

Jun Wei Ng
Towards Dev
Published in
3 min readMar 4, 2022

--

Sometimes the library/framework that we use forces us to initialise new objects within our Python code. This makes it difficult for us to write unit tests for these code that are succinct and easy to read. We will need to mock these object initialisation, and sometimes, it can become a rabbit hole when the function that you want to mock belongs to a deeply nested class within the mocked object. For example, mocking the find_one function of the mongo_client.get_database("some_db").get_collection("some_collection") collection object.

In another article I wrote, we explored mocking an object initialisation, where an object is being initialised within the function-under-test. From the article, we can clearly see that rabbit hole of mocking that was happening.

In this article, we will explore how we can use dependency injection to make development easier to test.

Photo by Jace & Afsoon on Unsplash

What is dependency injection?

Dependency injection is a technique where an object, known as the client, receives all other objects, known as services, that it depends on. It ensures that the client does not construct nor maintain any of its dependent services. This achieves separation of concerns, where the client’s concern on usage of its dependent services is separated from the concern of construction and management of these services.

Dependency injection is typically done in 3 ways — via the constructor, via a setter method, or via the method to be invoked. The basic idea is for the caller to provide the necessary dependencies to the class or method to be invoked.

Here are some examples of the 3 ways that dependency injection can be achieved:

#1. Via the constructor

The dependent services are passed to the client via the constructor method. This works best for services that need not be re-instantiated upon each use.

#2. Via setter method(s)

The dependent services are set in the client after client’s construction. This works best when the client requires changing services to achieve different behaviours.

#3. Via the method to be invoked

The dependent services are injected upon invocation of the method that uses them. This works best for services that need to be re-instantiated upon each use.

When we don’t use dependency injection

Consider the following example code, where we have not applied dependency injection:

In the initialisation of the ApiClient class, we need to pass in 4 parameters. These 4 parameters are used to ultimately create the self.collection object, which powers the core functionality of this (contrived) API client. None of the 4 parameters are used directly by the API client for any functionality. This makes the ApiClient class harder to use, as callers will need to understand how each of the parameters affect the initialisation of the class.

The ApiClient class also becomes harder to maintain, as the unit tests will require plenty of mocking. An example of the test for such a rendition of the code would be as such:

In order to test that we get the correct message using the specified message_id, we need to mock not only the MongoClient, but also the get_database() function, and the subsequent get_collection() function, before we can mock the find_one() function, which ultimately affects the behaviour of our code.

Applying dependency injection

Consider the following code, after we apply the concept of dependency injection via constructor:

Any upstream code that creates the ApiClient can clearly sees that it has an external dependency on a Mongo Collection. We can also name the dependency such that it is clear to the reader that it is the messages collection that this API client requires.

With dependency injection, we just need to mock the dependencies required by our function-under-test. In our case, it is just the message_collection Mongo collection. Notice now the test code is much shorter and easier to read (and also easier to maintain).

Where do we create these dependencies?

The dependencies are constructed upstream, usually as high upstream as possible such as in the main program flow. For our example, we create the message_collection dependency in the main() function.

Similarly, any class that depends on the ApiClient should then be constructed with the ApiClient being a parameter.

Conclusion

Dependency injection allows us to write better code and tests! If the situation allows, we should strive to use dependency injection.

Happy coding! (:

--

--