How to cover Django application with Unit Tests

Note: this tutorial builds on the skills and code developed in How to Create an API with Django. We will be working on the same ToDo list API, using this version on GitHub.

Last summer, I was working on a Django web application with some of my friends. One time, I completed a feature and sent it to another guy to test. He replied that he couldn't test the new feature because I'd inadvertently broken the sign in function. The problem was, this didn't just happen one time, something like it happened every week. The underlying issue was that our workflow didn't include sufficient automated testing.

What is automated testing?

Testing your code is an essential part of programming. Depending on your project, you might test your code by looking at a web page, playing through a video game, or analyzing output logs. Manual testing is time-intensive and leaves room for error, so most professional programmers dedicate serious attention to automated testing. Today, we are going to review a common type of automated testing, unit tests, to consider how automated testing can help us during the development process. First, we will write tests on the models and views of the existing API, then practice test-driven development by adding a new feature.

Automated testing saves time and improves software quality. Manual testing is quick when getting started: just run the code and see if it works. However, over time, you develop more and more features in the application, turning manual testing very time-intensive. Furthermore, you might forget to test a certain function. A proper implementation of automated testing covers everything, every time, in seconds. Having good automated testing also makes it easier for other people to understand your code and enables teams to build things together without worrying about breaking each others' features.

A unit test verifies the functionality of a component individually. It is the lowest level of testing; it makes sure that each aspect of the program works correctly alone (integration and systems tests validate the components together and their interactions, but that is beyond the scope of this tutorial). For example, in an object-oriented program, you would write unit tests for each object and, depending on the complexity, for individual methods. In Django, we unit test each model and view.

How does Unit Testing in Django work?

To follow along with this tutorial, clone the project from GitHub. Follow the same steps to set up the project as the first three paragraphs of How to Create an API with Django.

When you use python manage.py startapp appname to create a Django app, one of the files Django creates in the appname directory is called tests.py. This file exists to house unit tests for models and other components within the app. By default, the file contains one line of code: from django.test import TestCase. A Test Case contains multiple related tests for the same piece of code. TestCase is a Django object that we will inherit to build out own unit tests. The class has two methods, setUp(self) and tearDown(self), which run before and after the individual test functions to provide and clean up a testing database separate from whatever database you access with python manage.py runserver. Open up tests.py in the todo folder of the project to examine the code.

class SigninTest(TestCase):

    def setUp(self):
        self.user = get_user_model().objects.create_user(username='test', password='12test12', email='test@example.com')
        self.user.save()

    def tearDown(self):
        self.user.delete()

    def test_correct(self):
        user = authenticate(username='test', password='12test12')
        self.assertTrue((user is not None) and user.is_authenticated)

    def test_wrong_username(self):
        user = authenticate(username='wrong', password='12test12')
        self.assertFalse(user is not None and user.is_authenticated)

    def test_wrong_pssword(self):
        user = authenticate(username='test', password='wrong')
        self.assertFalse(user is not None and user.is_authenticated)

Here, we test the Sign In function. Because we are using built-in Django methods, as long as nothing is broken with the database, this should work fine, so we need only simple tests: if correct information is presented, authenticate, if incorrect information is given, don't. This test case lets us see a few more things about unit testing in Django. The first is that all test methods in a test case must begin with test_ in order to run when we execute the python manage.py test command. Other methods in the test case are considered helper functions. The other important piece is that all test methods must take self as an argument, where self is a reference to the TestCase object. TestCase, which we inherit to create our class, provides assertion methods to evaluate booleans. A self.assertSomething() call passes if the values passed as arguments are consistent with the assertion, and fails otherwise. A test method passes only if every assertion in the method passes.

class TaskTest(TestCase):

    def setUp(self):
        self.user = get_user_model().objects.create_user(username='test', password='12test12', email='test@example.com')
        self.user.save()
        self.timestamp = date.today()
        self.task = Task(user=self.user,
                         description='description',
                         due=self.timestamp + timedelta(days=1))
        self.task.save()

    def tearDown(self):
        self.user.delete()

    def test_read_task(self):
        self.assertEqual(self.task.user, self.user)
        self.assertEqual(self.task.description, 'description')
        self.assertEqual(self.task.due, self.timestamp + timedelta(days=1))

    def test_update_task_description(self):
        self.task.description = 'new description'
        self.task.save()
        self.assertEqual(self.task.description, 'new description')

    def test_update_task_due(self):
        self.task.due = self.timestamp + timedelta(days=2)
        self.task.save()
        self.assertEqual(self.task.due, self.timestamp + timedelta(days=2))

Now, we test our own model: the Task object defined in models.py. To set up the test case we create a user and a task (note that because of the ForeignKey relationship between task and user, deleting the user in tearDown() also deletes the task). Here, we see that any test method can have multiple assertions, and passes only if all assertions succeed. We can write to the database outside of the setup function, which we do when updating the task. Otherwise, this is a very similar test to the sign up test, indeed, most model test cases are just about creating, reading, updating, and destroying objects in the database, though models with methods are more interesting to test.

class SignInViewTest(TestCase):

    def setUp(self):
        self.user = get_user_model().objects.create_user(username='test',
                                                         password='12test12',
                                                         email='test@example.com')

    def tearDown(self):
        self.user.delete()

    def test_correct(self):
        response = self.client.post('/signin/', {'username': 'test', 'password': '12test12'})
        self.assertTrue(response.data['authenticated'])

    def test_wrong_username(self):
        response = self.client.post('/signin/', {'username': 'wrong', 'password': '12test12'})
        self.assertFalse(response.data['authenticated'])

    def test_wrong_pssword(self):
        response = self.client.post('/signin/', {'username': 'test', 'password': 'wrong'})
        self.assertFalse(response.data['authenticated'])

Testing views is somewhat more complicated than testing models. However, as we are writing an API, we don't have to worry about testing the front end like we would in a web app. Thus, we can replace much of our formerly manual testing via Postman with views tests. self.client is an HTTP client within the Django testing library. We use it to make a post request to "/signin/" with the user credentials. We test the same things as before: correct login info, wrong username, and wrong password. This is especially useful because it show us that if the model tests pass but the views tests fail, the issue not with the model, limiting the scope of our debugging. We do a similar thing for views related to tasks.

class AllTasksViewTest(TestCase):

    def setUp(self):
        self.user = get_user_model().objects.create_user(username='test',
                                                         password='12test12',
                                                         email='test@example.com')
        self.user.save()
        self.timestamp = date.today()
        self.client.login(username='test', password='12test12')

    def tearDown(self):
        self.user.delete()

    def test_no_tasks(self):
        response = self.client.get('/all/')
        self.assertEqual(response.data, {'tasks': []})

    def test_one_task(self):
        self.task1 = Task(user=self.user,
                          description='description 1',
                          due=self.timestamp + timedelta(days=1))
        self.task1.save()
        response = self.client.get('/all/')
        self.assertEqual(response.data, {'tasks': [OrderedDict([('id', 1),
                                                                ('description', 'description 1'),
                                                                ('due', str(self.timestamp + timedelta(days=1)))])]})

This case tests the "/all/" endpoint. The test case itself has more methods, but the snippet copied above shows us all of the new stuff. In the setup, we use self.client.login() so that the client acts like a logged-in user. Then, we create tasks and compare them to the formatted output that we expect. This example more clearly illustrates the benefits of the setUp() and tearDown() methods, as the tasks from one test do not carry over into the others. Again, this test isolates the view component, as the underlying model is separately tested.

Now that you understand the test code, run python manage.py test to run all tests. Let's examine the output:

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...FF..........
======================================================================
FAIL: test_due_future (todo.tests.DueTodayTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/Philip/Code/WFH/mkdev_blog/djangotesting/taskmanager/todo/tests.py", line 155, in test_due_future
    self.assertFalse(self.task.due_today())
AssertionError: True is not false

======================================================================
FAIL: test_due_past (todo.tests.DueTodayTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/Philip/Code/WFH/mkdev_blog/djangotesting/taskmanager/todo/tests.py", line 161, in test_due_past
    self.assertFalse(self.task.due_today())
AssertionError: True is not false

----------------------------------------------------------------------
Ran 15 tests in 2.232s

FAILED (failures=2)
Destroying test database for alias 'default'...

All passing tests are indicated with a . while failing tests receive an F. Failing tests yield errors explaining why the assertions failed. The tests we haven't talked about yet are failing, which we will fix in the next section. Before we proceed, you may notice that this testing code is incredibly verbose. Indeed, we have only tested a small amount of our functionality, and yet the tests file is already as many lines of code as the views file. This is to be expected, you really can't have too many tests as long as they are all accurate. When you change your code, some tests will break, which simply shows you what tests you need to fix. Thus, you should expect to continuously accumulate testing code, and expect that in an average production application you will have several times as many lines of tests as lines of code.

What is Test-Driven Development?

Let me return to the opening story for a moment. After weeks of fighting though bug after bug, my team and I wrote unit tests for the entire codebase. We had complete test coverage, meaning that every line of code was verified with at least one test. This lasted for a couple of weeks, until we decided to materially change our database schema. Rather than re-write the tests, we discarded the broken ones and within days we started experiencing "accidental breakage" bugs again. Test-driven development would have prevented this backsliding.

In order to stay useful, your tests require updating along with your code. Some programmers practice test-driven development to stay ahead of code changes. When you develop a feature, the first thing you do is define what the feature needs to do. Test-driven development formalizes this process by writing tests for that functionality first. The general idea is that you write one or more tests that define the feature, code until those tests pass, then repeat by writing more tests.

Returning to the failing tests shows us that we need to implement a due_today() method in the Task model. By examining the tests, we can see that it should return True if a task is due today, otherwise it should return False. Copy the code below to replace the existing due_today() method in the Task model then run the tests again.

def due_today(self):
    return self.due == date.today()

The tests pass, showing that our feature works and we can proceed. This method of development requires more thought and effort at the beginning to define the behavior of the code, but makes the actual coding process much more straightforward and achievable.

To test your understanding, try writing tests for the other existing views, or use tests to define new functions and then add those features. An easy one would be a boolean field completed in the task model that could be set to true once the task was done, allowing us to retain completed tasks instead of deleting them. Then, look into adding tests to your own projects. It can be daunting to stare down a large, untested project and try to bring it to full coverage. Rather than trying to test everything at once, add tests to small chunks of the project or new features as you develop them and build up to complete coverage.

Further Reference:

Cookies help us deliver our services. By using our services, you agree to our use of cookies.