Template Tests for Django TemplateView

A mixin for testing Django TemplateView class-based views to reduce having to write redundant test cases.

Background

As projects expand, some components may begin to look similar to others. Similar components mean similar tests. Rather than writing redundant tests, we can abstract tests that are common to these components and integrate them as a "mixin" or "decorator." This article will look at how to create a simples mixin for the tests we made in the previous article How to Test a Django TemplateView.

If you haven't already read this article, it may be worth giving it a quick skim so you have the appropriate context. If you'd like to follow along with the code on your local machine, you can clone the repo at django-templateview-test-utility.

Set Up

We left off with our test of the Django TemplateView with checks for the template name, status code, and URL location.

# demo.tests.test_demo_template_view_start.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_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, '/')

These three tests are a great starting point for abstraction because most of the statically-coded items are outside of the tests themselves. The exception is the URL path we're comparing the reversed URL to in test_good_location. We'll abstract that value as well.

Next, let's create a mixin so we don't need to write these tests each time we implement a TemplateView.

The Mixin

First, where should this mixin live? Because this is an abstracted "helper utility" that can be utilized anywhere in the project, I'm choosing to place this in a "utils" director with a "test" subdirectory to further organize any other test mixins or decorators I might produce down the road.

The mixin will look similar to the tests we began with above. Except, we'll drop the setUpTestData method. That will be implemented in the actual tests and not at the mixin level because the values that we need are specific to the TemplateView we're testing. What we'll add instead are checks to make sure these attributes have been appropriately set.

# utils.test.view_test_mixins.py

from django.views.generic import TemplateView


# noinspection PyUnresolvedReferences
class TemplateViewTestMixin:
    error_msg = 'The required attribute %s has not been set.'

    def test_correct_template(self):
        if not getattr(self, 'view_class'):
            raise AttributeError(self.error_msg % 'view_class')
        elif not getattr(self, 'template_name'):
            raise AttributeError(self.error_msg % 'template_name')
        elif not issubclass(getattr(self, 'view_class'), TemplateView):
            raise TypeError("Attr 'view_class' not a subclass of 'TemplateView.'")

        view = self.view_class()
        self.assertEqual(view.template_name, self.template_name)

    def test_good_status_code(self):
        if not getattr(self, 'url'):
            raise AttributeError(self.error_msg % 'url')

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

    def test_good_location(self):
        if not getattr(self, 'url'):
            raise AttributeError(self.error_msg % 'url')
        elif not getattr(self, 'static_url'):
            raise AttributeError(self.error_msg % 'static_url')

        self.assertEqual(self.url, self.static_url)

In the first method test_correct_template, I check that the attributes "view_class" and "template_name" are present and that "view_class" inherits from TemplateView. If anything isn't set correctly, I raise an exception and display an error message. In the second method test_good_status_code I check for the "url" attribute. And in the last method test_good_location I check for both the "url" and "static_url" attributes. Note, we should check attributes already checked in upstream methods because test ordering likely isn't in chronological order.

Now, let's put our mixin to use and see how many lines of code we save ourselves from needing to write.

Use

I'll reuse the same view and URL pattern that was used in the article How to Test a Django TemplateView.

# demo.views.py

from django.views.generic import TemplateView


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

The URL pattern begins from the root URL so the absolute path of the following is simply "/."

# demo.urls.py

from django.urls import path

from .views import DemoTemplateView


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

Now, we simply need to import the mixin and set the necessary attributes.

# demo.tests.test_demo_template_view.py

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

from utils.test.view_test_mixins import TemplateViewTestMixin
from ..views import DemoTemplateView


class TestDemoTemplateView(TemplateViewTestMixin, TestCase):

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

As you can see, we've dramatically cut down on the amount of code we needed to write to entirely cover our view. This, and similar abstractions, can speed up development time considerably.

Let's give it a run to make sure everything works properly.

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

# output

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

----------------------------------------------------------------------
Ran 3 tests in 0.012s

OK

Three good tests. Excellent.

Notice, the tests were not executed chronological order. Actually, they were executed inĀ alphabetical order. This is why we need the redundant check on the "url" attribute that's used in two different tests.

Refinement

You'll notice that the attribute checks themselves are a little redundant. The attribute name is really the only thing that's changing between checks. We can make this a bit more concise with a private method. I'll implement this as a separate file in the Github repo incase you prefer the former.

# utils.test.view_test_mixins_concise.py

from django.views.generic import TemplateView


# noinspection PyUnresolvedReferences
class TemplateViewTestMixin:
    error_msg = 'The required attribute %s has not been set.'
    
    def _check_attr(self, attr_name):
        if not getattr(self, attr_name):
            raise AttributeError(self.error_msg % attr_name)

    def test_correct_template(self):
        self._check_attr('view_class')
        self._check_attr('template_name')
        
        if not issubclass(getattr(self, 'view_class'), TemplateView):
            raise TypeError("Attr 'view_class' not a subclass of 'TemplateView.'")

        view = self.view_class()
        self.assertEqual(view.template_name, self.template_name)

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

    def test_good_location(self):
        self._check_attr('url')
        self._check_attr('static_url')

        self.assertEqual(self.url, self.static_url)

Here the _check_attr private method runs the check instead of repeating the checks in each test. Ironically, both approaches came out to be exactly 32 lines of code so we didn't save much in terms of size. However, to me this reads better and more closely follows "don't repeat yourself (DRY)" principles in my opinion.

Improper Configuration

What happens if we don't configure our test correctly for our mixin? Let's comment out a required attribute and see.

# demo.tests.test_demo_template_view.py

# imports

class TestDemoTemplateView(TemplateViewTestMixin, TestCase):

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

Now we run the test.

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

# output

test_correct_template (demo.tests.test_demo_template_view.TestDemoTemplateView.test_correct_template) ... ok
test_good_location (demo.tests.test_demo_template_view.TestDemoTemplateView.test_good_location) ... ERROR
test_good_status_code (demo.tests.test_demo_template_view.TestDemoTemplateView.test_good_status_code) ... ok

======================================================================
ERROR: test_good_location (demo.tests.test_demo_template_view.TestDemoTemplateView.test_good_location)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "django-templateview-test-utility/utils/test/view_test_mixins_concise.py", line 30, in test_good_location
    self._check_attr('static_url')
  File "django-templateview-test-utility/utils/test/view_test_mixins_concise.py", line 10, in _check_attr
    raise AttributeError(self.error_msg % attr_name)
AttributeError: The required attribute static_url has not been set.

----------------------------------------------------------------------
Ran 3 tests in 0.021s

FAILED (errors=1)

Our tests fail as expected and we're able to see exactly which test and attribute are causing the issue. Easy fix.

Final Thoughts

While writing tests are in integral part of software development, it can become a repetitive process if you're not careful. Abstractions like what's demonstrated in this article can significantly speed up the process. The quicker you can test your code, the quicker you can more on to building features you'll actually get paid for.

Source code for this project can be found on Github.

Details
Published
October 27, 2023
Next
September 25, 2023

Test Logging in Django

An easy way to test Django logging to ensure your logs are being created as expected.