How to Test a Django TemplateView

An overview of tests you should consider for your Django TemplateView class view.

It can sometimes be difficult to determine how far to go when writing tests. There's a tradeoff between ensuring components are properly covered and getting back to building real features. The following is a simple example of what I look for when testing a Django TemplateView class-based view.

For more information on writing tests in Django, please refer to the docs. Code for this article including a working demonstration is available at my Github repo django-templateview-testing.

Set Up

Assume we have the following simple class-based Django view consisting of a single attribute pointing to a template name.

# demo.views.py

from django.views.generic import TemplateView


class DemoTemplateView(TemplateView):
    template_name = "demo/demo-template.html"

This view is available with the URL pattern name "demo."

# demo.urls.py

from django.urls import path

from .views import DemoTemplateView


urlpatterns = [
    path('', DemoTemplateView.as_view(), name='demo'),
]

Some would argue that tests aren't needed for such a simple view, but I like to be thorough. It's my preference to test everything that could change. I'm from the camp that we aim for 100% code coverage, but that's a controversial topic. See this discussion. Generally, if I have 70-90% coverage, where core business logic has thorough coverage, I feel pretty good.

For this view, I want to make sure:

  • The template name is what we expect,
  • The URL pattern name reverses to a valid url with a good status code, and,
  • The URL exists where we think it should.

If we're providing additional context, we'll want to ensure those resources are present and displayed expected too.

Django TestCase

The tests will be constructed using the Django TestCase class. This class provides a lot of out-of-the-box functionality making it easy to cleanly and efficiently structure tests.

To get started we'll extend the TestCase class and prepare any data we may need. We have a couple methods we can use supply data to the tests including setUp() from the built-in python module unittest and setUpTestData() which is introduced at the Django TestCase level. The former is called before each test whereas the latter is called just once at the beginning of the broader test.

As a practical matter, I aim to use setUpTestData as much as possible given the performance advantage of running just once at the beginning of the test. Additionally, objects created in this method are still isolated between tests because each test is wrapped in an "atomic" block and non-database objects are deep copied. Following each test, state is rolled back so long as the database has transaction support and the objects created support deep copies. 

I'll demonstrate this using a simple test on the default user model. First, I'll create a user object in setUpTestData. Then in the first test, I'll update the object's "first_name" attribute and prove that the change doesn't persist into the second test.

# demo.tests.test_isolation.py

from django.test import TestCase
from django.contrib.auth import get_user_model


User = get_user_model()


class TestIsolation(TestCase):

    @classmethod
    def setUpTestData(cls):
        cls.user = User.objects.create(username="example")

    def test_update_first_name(self):
        # first_name is empty
        self.assertEqual(self.user.first_name, '')
        # update first_name and save to database
        self.user.first_name = "John"
        self.user.save()

        # confirm with a lookup this occurred
        u = User.objects.get(username="example")
        self.assertEqual(u.first_name, "John")

    def test_first_name_original_state(self):
        # test that first_name has returned to its original state
        self.assertEqual(self.user.first_name, "")

When we run the tests we'll see that they pass as demonstrated with "level 2 verbosity."

python manage.py test --verbosity 2

# output
test_first_name_original_state (demo.tests.test_isolation.TestIsolation.test_first_name_original_state) ... ok
test_update_first_name (demo.tests.test_isolation.TestIsolation.test_update_first_name) ... ok

With that out of the way, let's begin testing.

Check Template

First, let's check the template. The first option at our disposal is the Django assertTemplateUsed() method which is available to the TestCase class. This method, and many others, are actually members of the SimpleTestCase class which is then inherited by TransactionTestCase and then finally by TestCase through TransactionTestCase.

The method assertTemplateUsed() accepts four positional arguments: "response," "template_name," "msg_prefix," and "count." We'll mostly use only the first two arguments. Regarding the second two arguments, "msg_prefix" allows you to supply additional text to the error message should the assertion fail and "count" allows you to specify how many times you expect the template to be called.

Here's how we'll test our basic view using this particular method:

# demo.tests.test_demo_template_view.py

from django.test import SimpleTestCase
from django.urls import reverse


class TestDemoTemplateView(SimpleTestCase):

    # no setup needed

    def test_correct_template_used(self):
        url = reverse('demo')
        response = self.client.get(url)
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, 'demo/demo-template.html')

With the above test, we determine the URL, obtain a response object using the test client with the reversed URL, and finally check that the response contains the template we expect it to. Note, the test client is available to SimpleTestCase (and downstream test classes) by default. Last, while I normally use TestCase, we can get away with using SimpleTestCase because there aren't any database operations in this test.

Since everything matches up, our test passes.

python manage.py test demo.tests.test_demo_template_view --verbosity 2

# output

test_correct_template_user (demo.tests.test_demo_template_view.TestDemoTemplateView.test_correct_template_user) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.016s

OK

Alternative Template Check

The previous check is completely acceptable. However, it's not the fastest. The test creates a response object from the client which is comparatively slower than checking the template attribute on the class instance directly.

# demo.tests.test_demo_template_view.py

from django.test import TestCase
from django.urls import reverse

from ..views import DemoTemplateView


class TestDemoTemplateView(TestCase):

    @classmethod
    def setUpTestData(cls):
        cls.url = reverse('demo')
        cls.template_name = 'demo/demo-template.html'

    # def test_correct_template_used(self):
    #     response = self.client.get(self.url)
    #     self.assertEqual(response.status_code, 200)
    #     self.assertTemplateUsed(response, self.template_name)

    def test_correct_template_used_alt(self):
        view = DemoTemplateView()
        self.assertEqual(
            view.template_name,
            self.template_name
        )

In this test I switched from SimpleTestCase to TestCase so I could use the setUpTestData class method. Now that the URL and "template_name" are being used in two locations, it's better to centrally store those values so we're not repeating ourselves.

Let's take a look at performance.

python manage.py test demo.tests.test_demo_template_view --verbosity 2

# output

test_correct_template_used_alt (demo.tests.test_demo_template_view.TestDemoTemplateView.test_correct_template_used_alt) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.002s

OK

Whereas the first test took 16 milliseconds to run, this test only took 2 milliseconds. Now imagine we have many views and many templates to check. Simple choices like this can dramatically improve the time it takes to run tests.

Status Code

Now let's make sure we have a good status code for the response object produced by passing the view's URL to the client's "get" method. This was already checked in the example above with assertTemplateUsed, but let's be thorough.

For this, we'll use assertEqual. This method is inherited from the python built-in unittest.TestCase. It accepts three arguments: "first," "second," and "msg." The first two arguments are for what you'd like to compare. The last is an optional message to be displayed if the test fails.

# demo.tests.test_demo_template_view.py

# imports


class TestDemoTemplateView(TestCase):
    ...

    def test_good_status_code(self):
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 200)

With TemplateView, a good URL will yield a status code of 200 so we test equality against this integer. Depending on the type of response you're testing, you may need to test for a different status code. I'll run this test with the others commented out so you can see the isolated performance. I'll exclude the command for brevity since it's been demonstrated twice already.

test_good_status_code (demo.tests.test_demo_template_view.TestDemoTemnplateView.test_good_status_code) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.016s

OK

We're now back up to 16 milliseconds since a response object was needed to run the test.

Compare URLs

The last item I routinely check for a basic TemplateView, is that the reversed URL exists where I expect it to. To check this, I'll compare the reversed URL against a static URL. We'll use the same method as before, assertEqual, to accomplish this.

# demo.tests.test_demo_template_view.py

# imports


class TestDemoTemplateView(TestCase):
    ...

    def test_good_location(self):
        self.assertEqual(self.url, '/')

The view was registered with a URL pattern at the project's root URL location. This is confirmed by checking the equality of the reversed URL against a string containing a forward slash ("/"). As with before, the assertEqual here is light and runs fast.

test_good_location (demo.tests.test_demo_template_view.TestDemoTemplateView.test_good_location) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.002s

OK

Bringing It All Together

We now have three tests to ensure our TemplateView will behave as expected. Let's combine the tests and see how fast they run.

# demo.tests.test_demo_template_view.py

from django.test import TestCase
from django.urls import reverse

from ..views import DemoTemplateView


class TestDemoTemplateView(TestCase):

    @classmethod
    def setUpTestData(cls):
        cls.url = reverse('demo')
        cls.template_name = 'demo/demo-template.html'

    # def test_correct_template_used(self):
    #     response = self.client.get(self.url)
    #     self.assertEqual(response.status_code, 200)
    #     self.assertTemplateUsed(response, self.template_name)

    def test_correct_template_used_alt(self):
        view = DemoTemplateView()
        self.assertEqual(
            view.template_name,
            self.template_name
        )

    def test_good_status_code(self):
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 200)

    def test_good_location(self):
        self.assertEqual(self.url, '/')

And now the performance check.

test_correct_template_used_alt (demo.tests.test_demo_template_view.TestDemoTemplateView.test_correct_template_used_alt) ... ok
test_good_location (demo.tests.test_demo_template_view.TestDemoTemplateView.test_good_location) ... ok
test_good_status_code (demo.tests.test_demo_template_view.TestDemoTemplateView.test_good_status_code) ... ok

----------------------------------------------------------------------
Ran 3 tests in 0.010s

OK

Interestingly, the combined tests that ran faster than the 16 milliseconds it took to create the response object when that was an isolated check. That's more likely to do with the conditions of the machine I'm running the tests on than factors belonging to the tests themselves.

Alternative Implementation

You may have noticed that we moved away from the assertTemplateUsed method because we didn't want to introduce the less efficient "client." But, the client was used anyways in a separate test down the line. Can we use the same response object to test multiple items and be roughly as efficient? Sure, why not? (I'll mention why after).

# demo.tests.test_demo_template_view_alt.py

from django.test import TestCase
from django.urls import reverse


class TestDemoTemplateView(TestCase):

    @classmethod
    def setUpTestData(cls):
        cls.url = reverse('demo')
        cls.template_name = 'demo/demo-template.html'

    def test_template_view(self):
        response = self.client.get(self.url)
        # test template
        # (normally, I'd put this after status code but let's keep the same
        #   flow as before
        self.assertTemplateUsed(response, self.template_name)
        # test status code
        self.assertEqual(response.status_code, 200)
        # test location
        self.assertEqual(self.url, '/')

And now let's check performance.

test_template_view (demo.tests.test_demo_template_view_alt.TestDemoTemplateView.test_template_view) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.008s

OK

Not bad at 8 milliseconds.

For comparison, I ran both ways a number of times and the three discrete tests performed roughly the same as the combined test. That should be expected considering we just have the one client call and a couple equality assertions in each case.

Which way is better? I'd argue the discrete tests. This is unit testing and the objective is to test each component independently. Now that doesn't mean you have to test every single attribute by itself like we're doing here. But I prefer the tests to be as disaggregated as possible. Doing so makes tracing test failures a lot easier and it's not much more work for situations like these.

Final Thoughts

Testing can be tedious but it's an important part of software development. Determining how far to go with testing is equally important. There's an opportunity costs involved so redundant or excessive testing will be mutually exclusive with future development. We'd rather spend our time building than testing. But don't discount the time good tests can save us when code breaks. There's tradeoff here and finding a balance is important.

Efficiency should be something you're thinking about when writing tests. Reuse data where possible, avoid redundant checks and be sensitive to how tests are structured will affect performance. But most importantly, make sure you have good coverage beginning with your most important features. Aim for more coverage than less and keep building.

Here's the code again.

Details
Published
October 10, 2023
Next
November 9, 2023

Template Tests for Django TemplateView Requiring Staff Permissions

A mixin to be used with testing Django TemplateView class-based views that require staff privileges.