We all know that we should write tests to validate the functionality of our Django applications. However, it can be easy to fall into the trap of writing too many tests at the unit or integration level. Having thousands of unit tests is not helpful when the interactions between those units are not tested. To make matters worse, Django does not currently offer an opinionated way to write true integration tests that span multiple view requests. Fortunately, there is a way to write classic integration tests using the building blocks of the Django testing framework.
Typical tests in a Django project will evaluate models, views, serializers, and perhaps some utility functions. In this setup there are no integration tests that validate the functionality of a complete feature. Instead many unit tests are written with assumptions about how the state will be when the respective code is called. Naturally, it’s difficult to predict all of the edge cases and potential problems that will happen once various components of your code base are used together.
I would like to show how you can write integration tests in Django. I believe that integration tests are a good place to start when writing a new feature based on user stories. It’s easier to write tests at this level that closely approximate the interactions of the user even when your app is only an API backend for a front end application built in a JS framework.
The Testing Pyramid
The layers that make up testing responsibilities takes on a shape resembling a pyramid. There are lots of books and articles written about this topic. Pictured below is the testing pyramid featured in Martin Fowler’s blog about testing. The 3 layers of tests (UI, Service, and Unit tests) increase in cost and speed at the top of the pyramid. It’s important to know when to choose the best layer to focus on for testing. It’s better to have fewer tests at the UI level and many more tests at the unit level.
At the top of the pyramid we have the most expensive tests. These are the types of tests that QA will typically perform either manually or with an automated testing tool. These tests evaluate the entire stack. This includes front end client code and its interaction with the back end. It’s not atypical to have 3rd party services (payment gateways) included in this level of testing. This is the closest approximation to what users will see in production and it’s also the most expensive form of testing.
The service or integration layer sits below UI tests. At this level the server’s API is tested directly. Writing tests at this layer will be the focus of this article. At this level only 3rd party services are mocked when performing broad integration tests. Narrow integration tests (a single endpoint) can also be written at this layer to test the responses to typical inputs for the respective endpoint.
At the bottom of the pyramid there are unit tests. This should be the largest part of a test suite. With that said it shouldn’t be the only part of the test suite. Unit tests should be efficient and only test a small part of code with the dependencies mocked. At this level many lower level edge cases can be rapidly tested at a low cost. A very specific bug can be replicated in a unit test. Given the low cost of these tests there really is little penalty to writing many niche tests at this level.
All test layers are important but for this article we will focus on writing integration tests in Django. Some people might make the mistake of describing a View test as an integration test. This is partially true. Django is a Model-View-Template framework. A view test will interact with all 3 components of the framework. However, this is an example of a ‘narrow integration test’ in that it doesn’t test a broader user story or interaction that spans multiple requests. To serve this need we will need to write tests that support multiple requests. Fortunately this is easy enough to achieve in Django and does not require an additional testing library.
I like to separate multi-request view tests from the single request views. When testing multiple requests I find that it’s helpful to create a base class that provides some convenient wrapper functions for quickly generating requests with some sensible defaults. I like to think of this base class as an endpoint factory.
BaseRequestTestCase can then be used to write new TestCase classes that inherit from this class. URLs can easily be generated to support multiple requests and test an entire user story.
Imagine that there is a user story, however unlikely, that calls for the addition and deletion of a student all in one single step. This test will evaluate the entire stack of the Django application for this particular feature. Often times there could be side effects that result from creating an object via an endpoint that are not present or maintained in a model factory.
The main takeaway is that we can create convenient base class methods for an application API that can be used in multiple request tests.
When Unit Tests are More Appropriate
There are times where unit tests are more appropriate. I generally write unit tests for any low level changes to a model, utility function or schema. As mentioned previously, specific fail cases for bugs are often helpful to write at this level. I would rather pay as little cost as I can to verify an edge case while still including the test in my test suite.
Integration tests are possible in Django even though it’s not immediately obvious. I shared how I like to write integration tests that simulate user interactions that span multiple API requests. Tests at this level should be used sparingly given the higher cost to maintain and run. However, a test suite is not complete without tests that test high level user features.