IAN WALDRON IAN WALDRON

Template Tests for Django TemplateView Requiring Staff Permissions

A mixin to be used with testing Django TemplateView class-based views that require staff privileges and certain permissions.
November 9, 2023

Introduction

In the article How to Test a Django TemplateView Requiring Staff Permissions we explored how to test the Django class-based TemplateView that requires staff privileges and a user possessing certain permissions. Here, we'll abstract those tests as a mixin so they can be reused across staff only views.

Set Up

In constructing a test for a staff-only, permissions required Django Template View, we arrive at the following:


from uuid import uuid4

from django.urls import reverse
from django.contrib.auth import get_user_model

from apps.article.models import Article


User = get_user_model()

class TestArticleDetailView(TestCase):
    ...

    def setUp(self) -> None:
        uuid_string = str(uuid4())

        first_name = "John"
        last_name = f"Smith-{uuid_string}"
        self.standard_user = User.objects.create(
            first_name=first_name, 
            last_name=last_name,
            email=f"{first_name}.{last_name}@company.com"

        first_name = "Jane"
        last_name = f"Doe-{uuid_string}"
        self.staff_user = User.objects.create(
            first_name=first_name, 
            last_name=last_name,
            email=f"{first_name}.{last_name}@company.com"

        self.article = Article.objects.create(title="A Title", content="Article content.")

        self.reversed_url = reverse("admin_article:article-detail", kwargs={"article": article.pk})
        self.static_url = f"/admin/article/{self.article.pk}/"

    def test_staff_only(self):
        # test 403 for non-staff, but has perms
        self.standard_user.user_permissions.add(*self.perms)
        self.client.force_login(self.standard_user)
        response = self.client.get(self.reversed_url)
        self.assertEqual(response.status_code, 403)

        if self.perms:
            # test 403 for is-staff, but no perms
            # only works if one or more permissions is present
            self.client.force_login(self.staff_user)
            response = self.client.get(self.reversed_url)
            self.assertEqual(response.status_code, 403)

        # test 200 for is-staff and has perms

        # add each permission one at a time so that we know a good status code isn't granted for anything
        #   less than full permission

        # since has_perms uses strings and not the actual permission objects, we need to convert the permissions to
        #   a list of strings for the test
        perm_strings = [f"{p.content_type.app_label}.{p.codename}" for p in self.perms]
        for perm in self.perms:
            self.staff_user.user_permissions.add(perm)
            self.client.force_login(self.staff_user)
            response = self.client.get(self.reversed_url)
            if self.staff_user.has_perms(perm_strings):
                # all perms have been added to the user so this should be the last item and the status code
                #   should be 200
                self.assertEqual(response.status_code, 200)
            else:
                # we're at some stage user perms < total perms
                # we should get permission denied 403
                self.assertEqual(response.status_code, 403)

    def test_view_url(self):
        # we already know we have a good status code with the reversed url if test_staff_only hasn't already failed
        # so, just test the reversed url against the static url to ensure the reversed url exists where we think it
        #   should

        self.assertEqual(self.reversed_url, self.static_url)

    def test_uses_correct_template(self):
        self.staff_user.user_permissions.add(*self.perms)
        self.client.force_login(self.staff_user)
        response = self.client.get(self.reversed_url)
        self.assertEqual(response.status_code, 200)  # this has already been tested above
        self.assertTemplateUsed(response, self.template_name)

In this test, we establish a bit of set-up data to run our tests with. Then we check that a non-staff user with required permissions is denied, a staff user well less than full permission is denied, and finally that a staff user with full permissions is allowed access. There's a bit of code required in this test. Much of that code will be repeated across staff only, and permissions required view tests. Now we'll generalize what we can and construct a simple mixin.

The Test Mixin

We'll label our mixin 'AdminViewTestMixin' to differentiate from the 'ViewTestMixin' established in Template Tests for Django TemplateView. And just as we did in the simpler mixin, we'll add a few attribute checks and the beginning of each test unit and use a simple, local method to perform the test for us so we're not repeating code. The check will raise an AssertionError if the attribute isn't set properly and provide us with a message alerting to which attribute is causing problems.

The attribute checks are only checking is not none. Although we're checking more complex data constructs than in the prior mixin, such as the User class, I'm not concerned with checking is-instance. The reason is, we're performing operations with the User instances, such as adding permissions, that are unique to the User class. So, the tests will fail in a reasonably predictable, and detailed way if the values stored in the 'standard_user' and 'staff_user' attributes are not actually instances of User.

Here's what our mixin looks like with the mentioned attribute checks:


# noinspection PyUnresolvedReferences
class AdminViewTestMixin:  # pragma: no cover

    def _check_attr(self, attr_name):
        self.assertIsNotNone(
            getattr(self, attr_name, None),
            msg=f"The required attribute '{attr_name}' has not been set causing the test to fail."
        )

    def test_staff_only(self):
        # -- with reversed url --
        self._check_attr("perms")
        self._check_attr("reversed_url")
        self._check_attr("standard_user")
        self._check_attr("staff_user")

        # test 403 for non-staff, but has perms
        self.standard_user.user_permissions.add(*self.perms)
        self.client.force_login(self.standard_user)
        response = self.client.get(self.reversed_url)
        self.assertEqual(response.status_code, 403)

        if self.perms:
        # test 403 for is-staff, but no perms
        # only works if one or more permissions is present
            self.client.force_login(self.staff_user)
            response = self.client.get(self.reversed_url)
            self.assertEqual(response.status_code, 403)

        # test 200 for is-staff and has perms

        # add each permission one at a time so that we know a good status code isn't granted for anything
        #   less than full permission

        # since has_perms uses strings and not the actual permission objects, we need to convert the permissions to
        #   a list of strings for the test
        perm_strings = [f"{p.content_type.app_label}.{p.codename}" for p in self.perms]
        for perm in self.perms:
            self.staff_user.user_permissions.add(perm)
            self.client.force_login(self.staff_user)
            response = self.client.get(self.reversed_url)
            if self.staff_user.has_perms(perm_strings):
                # all perms have been added to the user so this should be the last item and the status code
                #   should be 200
                self.assertEqual(response.status_code, 200)
            else:
                # we're at some stage user perms < total perms
                # we should get permission denied 403
                self.assertEqual(response.status_code, 403)

    def test_view_url(self):
        # we already know we have a good status code with the reversed url if test_staff_only hasn't already failed
        # so, just test the reversed url against the static url to ensure the reversed url exists where we think it
        #   should
        self._check_attr("reversed_url")
        self._check_attr("static_url")
        self.assertEqual(self.reversed_url, self.static_url)

Here, we lose our imports as well as our 'setUpTestData' and 'setUp' methods since those will occur in our actual test. A local method is added to perform our attribute checks, and each test begins with a call to the checker method supplying the attribute name as a required argument. Now let's see how to use it.

Use

Let's assume we're using the same view and url pattern as we did in the article How to Test a Django TemplateView Requiring Staff Permissions:


# view

from django.views.generic import DetailView
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin, PermissionRequiredMixin

from apps.article.models import Article


class ArticleDetailView(LoginRequiredMixin, UserPassesTestMixin, PermissionRequiredMixin, DetailView):

    permission_required = ['article.view_article',]
    raise_exception = True

    template_name = 'admin_article/article-detail.html'
    model = Article
    pk_url_kwarg = 'article'

    def test_func(self):
        return self.request.user.is_staff

# url
from django.urls import path

from admin_app.views import ArticleDetailView


app_name = "admin_article"

urlpatterns = [
    path("admin/article/<int:article>/", ArticleDetailView.as_view(), name="article-detail"),
]

Constructing a test to fully cover this view is as simple as:


from uuid import uuid4

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

from utils.test.mixins import AdminViewTestMixin

class TestArticleDetailView(AdminViewTestMixin, TestCase):

class TestArticleDetailView(TestCase):

    @classmethod
    def setUpTestData(cls):
        perms = [
            Permission.objects.get_by_natural_key(
                codename="view_article",
                app_label="article",
                model="article",
            ),
        ]
        cls.perms = perms

        cls.template_name = "admin_article/article-detail.html"

    def setUp(self) -> None:
        uuid_string = str(uuid4())

        first_name = "John"
        last_name = f"Smith-{uuid_string}"
        self.standard_user = User.objects.create(
            first_name=first_name, 
            last_name=last_name,
            email=f"{first_name}.{last_name}@company.com"

        first_name = "Jane"
        last_name = f"Doe-{uuid_string}"
        self.staff_user = User.objects.create(
            first_name=first_name, 
            last_name=last_name,
            email=f"{first_name}.{last_name}@company.com"

        self.article = Article.objects.create(title="A Title", content="Article content.")

        self.reversed_url = reverse("admin_article:article-detail", kwargs={"article": article.pk})
        self.static_url = f"/admin/article/{self.article.pk}/"

All we need to do is properly construct the 'setUpTestData' and 'setUp' methods, and we have a covered view. That said, you'll likely have greater functionality beyond displaying a template you're going to want to cover. This allows you to get to testing what's important and not wasting time with repetitive testing.

As mentioned in How to Test a Django TemplateView Requiring Staff Permissions, it's a lot easier to construct model instances using model factories that can produce instances for you with a single line of code. I use factory_boy. Using a factory would considerably cut down on the bloat seen in the set-up process. Factories also have functionality to ensure uniqueness so uuid wouldn't be needed. In addition to factories, Django has the Fixtures feature that allows you to pre-populate your testing environment.

Final Thoughts

Testing for good coverage can be tedious. You'll find that you often test similar components in similar ways. When it feels like you're beginning to repeat yourself, that's a sign you may want to abstract that code into a mixin. Importantly, repetitive processes can lead to mistakes. Especially with tests like these where we're concerned with access and security, we don't want any slip ups where private areas of an application can become exposed to the public. By making the testing process simpler, we're reducing the probability of these errors or omissions occurring. By abstracting repetitive code, we're allowing focus to be applied to a view's unique functionality. 

Source code can be found on GitHub.