Testing Django decorators

How to test view decorators of Django applications? Here are some tips.

In a post before, I recommended to avoid decorating views in place (i.e. not in views.py). Once decorators and views are separated, we can unit test the views. That was the topic of the post before. This article focuses on testing decorators.

The examples described below are available as a Python file at https://gist.github.com/benoitbryon/5156512

Use unittest.mock

Learn about unittest.mock [2] (or backward-compatible mock [1]) library! This article makes heavy use of those wonderful features.

In tests.py:

try:
    from unittest import mock
except ImportError:
    import mock

And in project's or app's setup.py:

# ...
requirements = []
try:
    from unittest import mock
except ImportError:
    requirements.append('mock')
# ...
setup(
    # ...
    install_requires(requirements),
    # ...
)

Fake the request

We want to focus on the decorator. Does it rely on the request to perform some actions? Fake/stub/mock all and only what you need.

django.test.RequestFactory [3] can be useful. But sometimes it is overkill and django.http.HttpRequest [4] is enough.

In the hello world example below, we'll use a completely fake request:

request = 'fake request'

In the authenticated_user_passes_test example below, we use mocks to support request.user.is_authenticated():

request = mock.MagicMock()
request.user.is_authenticated = mock.MagicMock(return_value=True)

Stub the decorated view

We want to focus on the decorator. We don't care about decorated view implementation. But we care about how the decorator handles the view. Let's use unittest.mock [2].

Decorated views are functions (or callables). We can instantiate and check a mocked-view like this:

import unittest

class MockViewTestCase(unittest.TestCase):
    def test_stub(self):
        # Setup.
        request = 'fake request'
        view = mock.MagicMock(return_value='fake response')
        # Run.
        response = view(request)
        # Check.
        view.assert_called_once_with(request)
        self.assertEqual(response, view.return_value)

hello_world decorator

Before we dive into a real-life example, let's consider a really simple one.

Here is the decorator:

from django.http import HttpResponse

def hello_world(view_func):
    """Run the decorated view, but return "Hello world!"."""
    def decorated_view(request, *args, **kwargs):
        view_func(request, *args, **kwargs)
        return HttpResponse(u'Hello world!')
    return decorated_view

Here is the test case:

import unittest

class HelloWorldTestCase(unittest.TestCase):
    def test_hello_decorator(self):
        """hello_world decorator runs view and returns greetings."""
        # Setup.
        request = 'fake request'
        request_args = ('foo', )
        request_kwargs = {'bar': 'baz'}
        view = mock.MagicMock(return_value='fake response')
        # Run.
        decorated = hello_world(view)
        response = decorated(request, *request_args, **request_kwargs)
        # Check.
        # View was called.
        view.assert_called_once_with(request, *request_args, **request_kwargs)
        # But response is "Hello world!".
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.content, u"Hello world!")

The test looks like a documentation for the decorator :)

Some noticeable points:

  • we haven't used Django's builtin test client. We haven't needed to setup URLconfs, settings, "real" views...
  • there is no database transaction involved, so we used unittest.TestCase.

authenticated_user_passes_test decorator

Now let's consider a real-life example, with a custom decorator:

from functools import wraps

from django.utils.decorators import available_attrs

def authenticated_user_passes_test(test_func,
                                   unauthorized=UnauthorizedView.as_view(),
                                   forbidden=ForbiddenView.as_view()):
    """Make sure user is authenticated and passes test.

    This is an adaptation of
    ``django.contrib.auth.decorators.user_passes_test`` where:

    * if user is anonymous, the request is routed to ``unauthorized`` view.
      No additional tests are performed in that case.

    * if user is authenticated and doesn't pass ``test_func ``test, the
      request is routed to ``forbidden`` view.

    * else, request and arguments are passed to decorated view.

    Typical ``unauthorized`` view returns HTTP 401 status code and gives the
    user an opportunity to log in: access may be granted after
    authentication.

    Typical ``forbidden`` view returns HTTP 403 status code: with active
    user account, access is refused. As explained in rfc2616, 401 and 403
    status codes could be suitable.

    .. seealso::

       * http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4
       * https://en.wikipedia.org/wiki/List_of_HTTP_status_codes

    """
    def decorator(view_func):
        @wraps(view_func, assigned=available_attrs(view_func))
        def _wrapped_view(request, *args, **kwargs):
            if not request.user.is_authenticated():
                return unauthorized(request)
            if not test_func(request.user):
                return forbidden(request)
            return view_func(request, *args, **kwargs)
        return _wrapped_view
    return decorator

This decorator depends on some additional stuff:

from django.http import HttpResponse, HttpResponseForbidden
from django.views.generic import TemplateView

class HttpResponseUnauthorized(HttpResponse):
    status_code = 401

class UnauthorizedView(TemplateView):
    response_class = HttpResponseUnauthorized
    template_name = '401.html'

class ForbiddenView(TemplateView):
    response_class = HttpResponseForbidden
    template_name = '403.html'

And, here is the test case! It seems quite long, but isn't it readable? The whole test lives in the TestCase: no external URLconf, no external views...

  • First we setup fakes or mocks for all dependencies: request.user, test_func, unauthorized view, forbidden view, and the view to be decorated.
  • Then we declare a run_decorated_view function to avoid repeating code.
  • Finally we test the 3 main situations: unauthorized, forbidden, authorized.
import unittest

class AuthenticatedUserPassesTestTestCase(unittest.TestCase):
    def setUp(self):
        """Common setup: fake request, stub views, stub user test function."""
        super(AuthenticatedUserPassesTestTestCase, self).setUp()
        # Fake request and its positional and keywords arguments.
        self.request = mock.MagicMock()
        self.request.user.is_authenticated = mock.MagicMock()
        self.request_args = ['fake_arg']
        self.request_kwargs = {'fake': 'kwarg'}
        # Mock user test function.
        self.test_func = mock.MagicMock()
        # Mock unauthorized and forbidden views.
        self.unauthorized_view = mock.MagicMock(
            return_value=u"401 - You may log in.")
        self.forbidden_view = mock.MagicMock(
            return_value=u"403 - Insufficient privileges.")
        # Mock the view to decorate.
        self.authorized_view = mock.MagicMock(
            return_value=u"200 - Greetings, Professor Falken.")

    def run_decorated_view(self, is_authenticated=True, user_passes_test=True):
        """Setup, decorate and call view, then return response."""
        # Custom setup.
        self.request.user.is_authenticated.return_value = is_authenticated
        self.test_func.return_value = user_passes_test
        # Get decorator.
        decorator = authenticated_user_passes_test(
            self.test_func,
            unauthorized=self.unauthorized_view,
            forbidden=self.forbidden_view)
        # Decorate view.
        decorated_view = decorator(self.authorized_view)
        # Return response.
        return decorated_view(self.request,
                              *self.request_args,
                              **self.request_kwargs)

    def test_unauthorized(self):
        """authenticated_user_passes_test first tests user authentication."""
        response = self.run_decorated_view(is_authenticated=False)
        # Check: unauthorized view was called with request as unique positional
        # argument.
        self.unauthorized_view.assert_called_once_with(self.request)
        self.assertEqual(response, self.unauthorized_view.return_value)
        # Test func was not called.
        self.assertFalse(self.test_func.called)
        # Of course, authorized and forbidden views were not called.
        self.assertFalse(self.authorized_view.called)
        self.assertFalse(self.forbidden_view.called)

    def test_test_func_args(self):
        """authenticated_user_passes_test passes user instance to test func."""
        self.run_decorated_view(is_authenticated=True)
        # Check: test_func was called with one argument: user instance.
        self.test_func.assert_called_once_with(self.request.user)

    def test_forbidden(self):
        """authenticated_user_passes_test runs forbidden view if user fails."""
        response = self.run_decorated_view(is_authenticated=True,
                                           user_passes_test=False)
        # Check: forbidden view was called with request as unique positional
        # argument.
        self.forbidden_view.assert_called_once_with(self.request)
        self.assertEqual(response, self.forbidden_view.return_value)
        # Of course, authorized and unauthorized views were not triggered.
        self.assertFalse(self.authorized_view.called)
        self.assertFalse(self.unauthorized_view.called)

    def test_authorized(self):
        """authenticated_user_passes_test runs view if user passes test."""
        response = self.run_decorated_view(is_authenticated=True,
                                           user_passes_test=True)
        # Check: decorated view has been called, request and other arguments
        # were proxied as is, response was not altered.
        self.authorized_view.assert_called_once_with(self.request,
                                                     *self.request_args,
                                                     **self.request_kwargs)
        self.assertEqual(response, self.authorized_view.return_value)
        # Of course, forbidden and unauthorized views were not triggered.
        self.assertFalse(self.forbidden_view.called)
        self.assertFalse(self.unauthorized_view.called)

Would you trust the authenticated_user_passes_test decorator?

Comments !