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.