How to Test a Django TemplateView
When writing tests in Django, I often find it difficult to determine how far to go. Django components and other external code doesn’t need to be covered. Yet, it may still be prudent to make sure certain attributes have been set and remain the values we expect. This article is a brief introduction for what tests I generally write for the TemplateViews I encounter.
For more information on writing tests in Django, please refer to the docs.
Set Up
To start, let's assume we have the following simple class-based Django view consisting of a single attribute that stores the name of our template.
from django.views.generic import TemplateView
class MyTemplateView(TemplateView):
template_name = "my_app/my-template.html"
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, and others.
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.
Additionally, if we're providing additional context, we'll want to ensure that those resources are present and as expected.
Django TestCase
To write these tests, we're going to use Django's TestCase class. This class provides a lot of out of the box functionality to cleanly and efficiently structure a test and prepare data, evaluate the tests, and tear down the environment following the conclusion of the tests.
To get started we'll extend the TestCase class and prepare any data we may need. In setting up data, we have a couple methods we can use to supply our data: 'setUpTestData' and 'setUp.' The former is a class method and is called just once at the beginning of the broader test. Alternatively, we have the method 'setUp' which is called before each test. If we're not concerned about changes to data affecting downstream tests, setUpTestData would be more efficient. If we need clean data that we're sure hasn't been affected from anything upstream, then use the setUp method.
As a practical matter, I like to use setUpTestData for more static data types, such as strings or immutables like tuples. If there's a possibility that the tests themselves will affect the data and thus impact a subsequent test, such as a model instance, I generally put these resources in setUp. But this means that for each test, a model instance will be created so consider how this will affect efficiency.
Consider the following example and which method I'm using to initialize data to be used by the tests.
from django.test import TestCase
from django.contrib.auth import get_user_model
User = get_user_model()
class MyTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.template_name = "my_app/my-template.html"
def setUp(self) -> None:
self.user = User.objects.create(first_name="John", last_name="Smith", email="jsmith@example.com")
...
# my tests
Note, setUpTestData is a class method. And as such, we must wrap this method with the '@classmethod' python decorator. Additionally, we use the structure 'cls.attribute_name' to set values instead of 'self.attribute_name' and the method accepts 'cls' instead of 'self' as its default and required first positional argument.
Last thing before we jump in, there's another option to provide data to our tests, Django Fixtures. Fixtures are data stored in files in the form of JSON, XML or YAML (though I tend to use JSON). Importing this data is as simple as:
class MyTest(TestCase):
fixtures = ["file_location/fixture_name.json",]
...
Fixtures are called once at the beginning of the test before setUpTestData so consider how this order will affect your test.
Now that we know how to establish our data, let's begin testing.
Check Template
First, let's check the template. For this, we'll use Django's 'assertTemplateUsed()' method which is available to the TestCase class. This method, and many other's we'll often use, 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 just be dealing with the first two arguments in most circumstances. Of the second two, 'msg_prefix' allows you to supply additional text to the error message should the assertion fail. And finally, 'count' allows you to specify how many times you expect the template to be called.
Here's how we'll test our basic view with this method:
from django.test import TestCase
from django.urls import reverse
class MyTest(TestCase):
def test_uses_correct_template(self):
url = reverse("pattern-name")
response = self.client.get(url)
self.assertTemplateUsed(response, "my_app/my-template.html")
With the above test, we determine the url, obtain a response object using the test client, and finally check that the response uses the template we expect. Note, the test client (self.client) is available to the test by default using TestCase.
Status Code
Next, 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. For this, we'll use assertEqual. This method is a test inherited from Python's unittest.TestCase. This method accepts three arguments: 'first,' 'second,' and 'msg.' The first two arguments are what you'd like to compare. The last is an optional message to be displayed if the test fails.
This is how we'll check the status code:
from django.test import TestCase
from django.urls import reverse
class MyTest(TestCase):
...
def test_status_code(self):
url = reverse("pattern-name")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
With TemplateView, a good URL will yield a status code of 200 so we test equality with this integer. Depending on the type of response you're testing, you may need to test for a different status code.
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.
from django.test import TestCase
from django.urls import reverse
class MyTest(TestCase):
...
def test_urls(self):
reversed_url = reverse("pattern-name")
static_url = "/some-static-url/"
self.assertEqual(reversed_url, static_url)
Bringing It All Together
With the above, we now have three tests to ensure our TemplateView will behave as expected. Now let's condense what we've written and reduce redundancies where possible for more efficient code.
To reduce code and simplify, we can combine our template test with our status code test since we need a response object in both cases. We can further reduce redundancy by defining our URLs in the setUpTestData method instead of within each of our tests.
from django.test import TestCase
from django.urls import reverse
class MyTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.reversed_url = reverse("pattern-name")
cls.static_url = "/some-static-url/"
def test_uses_correct_template(self):
response = self.client.get(self.reversed_url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "my_app/my-template.html")
def test_urls(self):
# response checked in test_uses_correct_template, no need to check again here
self.assertEqual(self.reversed_url, self.static_url)
Other Tests
The above tests are sufficient for a basic TemplateView. But when we extend the views with custom functionality, we'll need to test the added features. A common situation would be where we add additional context. Consider the following view:
from django.views.generic import TemplateView
class RandomClass:
pass
class MyTemplateView(TemplateView):
template_name = "my_app/my-template.html"
def get_context_data(self, **kwargs)
context = super().get_context_data(**kwargs)
context["random_class"] = RandomClass()
return context
Here we've supplied a class to our view's get_context_data method to be used somehow with our template rendering. A common real-world example would be passing a certain model instance. With this set up we can test whether something named "random_context" exists within our response's context dictionary as well as whether the class is the instance we're expecting. Practically I would do one test or there other since checking if something present in context is of a certain would fail if not present at all. But let's look at both since there are times when simply checking if a resource is present is sufficient.
from django.test import TestCase
from django.urls import reverse
class MyTest(TestCase):
...
def test_get_context_data(self):
url = reverse("pattern-name")
response = self.client.get(url)
self.assertIn("random_class", response.context) # just check if present
self.assertIsInstance(response.context["random_class"], RandomClass) # check if desired class
With 'assertIn,' we provide two arguments. The first is the name/key to the resource stored in our context dictionary. If this name is found in the available keys, the test will pass regardless of what the resource is. If we're concerned about the substance of the resource, then we'll check 'assertIsInstance.' Here we pass the resource stored in our context dictionary followed by the instance we wish to compare the resource against.
Final Thoughts
Testing can be tedious but is an important part of software development. Determining how far to go with testing is equally important. There's an opportunity cost to our time so redundant or excessive testing will come at the cost of 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 combine tests when you can. But most importantly, make sure you have good coverage beginning with your most important features. Aim for more coverage than less, but just the same, keep building.