How to mock view decorator in Django

Viewed 748

Consider that I have a simple APIView as below,

from rest_framework.views import APIView
from rest_framework.response import Response


def my_custom_decorator(func):
    def wrap(view, request):
        if request.method.lower():
            raise ValueError("Just for testing")
        return func(view, request)

    return wrap


class SomeAPIView(APIView):

    @my_custom_decorator
    def post(self, request):
        return Response({"message": "Success"})

Note that the view function post(...) is wrapped by the decorator @my_custom_decorator. Noe, I want to write the test for this API and I tried like this

from rest_framework.test import APITestCase
from django.urls import reverse
from unittest.mock import patch


class TestSomeAPIView(APITestCase):

    @patch("sample.views.my_custom_decorator")
    def test_decorator(self, mock_my_custom_decorator):
        url = reverse("some-api-view")
        response = self.client.post(url)
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json(), {"message": "Success"})

This didn't mock the @my_custom_decorator properly and thus gave me an exception.

Question: How can I mock the @my_custom_decorator to retrieve a successful response?

Notes

Update - 1

This answer will work only if the test module gets initialized before the initialization of the view module. AFAIK, this kind of loading isn't configurable in Django.

4 Answers

This isn't a guaranteed solution but depending on your needs it may work to rewrite your decorator with a helper function that contains the logic that needs to be mocked.

For example:

from rest_framework.views import APIView
from rest_framework.response import Response

def some_check_or_other_response(view, request):
    if request.method.lower():
        raise ValueError("Just for testing")
    if some_other_condition:
        return Response({})
    

def my_custom_decorator(func):
    def wrap(view, request):
        short_circuit_response = some_check_or_other_response(view, request)
        if short_circuit_response:
            return short_circuit_response
        return func(view, request)

    return wrap


class SomeAPIView(APIView):

    @my_custom_decorator
    def post(self, request):
        return Response({"message": "Success"})

and then

class TestSomeAPIView(APITestCase):

    @patch("sample.views.some_check_or_other_response")
    def test_decorator(self, mock_some_check):
        mock_some_check.return_value = ... # short-circuit with a return value
        mock_some_check.side_effect = ValueError(...) # simulate an exception
        ... # etc

First, you will need to move my_custom_decorator into another module, preferably within the same package as your views.py.

Then you need to:

  • Clear the module import cache for sample.decorators, all modules within your app that import it, and your settings.ROOT_URLCONF

  • Clear the url cache that django uses internally

  • Monkey patch the decorator

tests.py:

import sys
from django.conf import settings
from django.urls import clear_url_caches

def clear_app_import_cache(app_name):
    modules = [key for key in sys.modules if key.startswith(app_name)]

    for module_name in modules:
        del sys.modules[module_name]
    
    try:
        del sys.modules[settings.ROOT_URLCONF]
    except KeyError:
        pass
    clear_url_caches()

class TestSomeAPIView(APITestCase):
    @classmethod
    def setUpClass(cls):
        clear_app_import_cache('sample')

        from sample import decorators
        decorators.my_custom_decorator = lambda method: method

        super().setUpClass()

    @classmethod
    def tearDownClass(cls):
        # Make sure the monkey patch doesn't affect tests outside of this class.  
        # Might not be necessary
        super().tearDownClass()
        clear_app_import_cache('sample')

    def test_decorator(self):
        url = reverse("some-api-view")
        response = self.client.post(url)
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json(), {"message": "Success"})

First you need to move the decorator to a different module to get a change to mock it.

decorators.py

def my_custom_decorator(func):
    def wrap(view, request):
        if request.method.lower():
            raise ValueError("Just for testing")
        return func(view, request)
    return wrap

views.py

from decorators import my_custom_decorator

class SomeAPIView(APIView):

    @my_custom_decorator
    def post(self, request):
        return Response({"message": "Success"})

In your tests patch the decorator before it get applied, like this

tests.py

from unittest.mock import patch
patch("decorators.my_custom_decorator", lambda x: x).start()

from rest_framework.test import APITestCase

from django.urls import reverse


class TestSomeAPIView(APITestCase):

    def test_decorator(self):
        url = reverse("some-api-view")
        response = self.client.post(url)
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json(), {"message": "Success"})

For simplicity, I think it is better to split the logic from the decorator to somewhere else. So, I created a function named _my_custom_decorator(...)

def _my_custom_decorator(func, view, request):
    # do most of the decorator logic here!!!
    if request.method.lower():
        raise ValueError("Just for testing")
    return func(view, request)


def my_custom_decorator(func):
    def wrap(view, request):
        return _my_custom_decorator(func, view, request) # calling the newly created function
    return wrap


class SomeAPIView(APIView):
    @my_custom_decorator # this decorator remain unchanged!!!
    def post(self, request):
        return Response({"message": "Success"})

and now, mock the _my_custom_decorator(...) function in the tests,

def mock_my_custom_decorator(func, view, request):
    return func(view, request)


class TestSomeAPIView(APITestCase):

    @patch("sample.views._my_custom_decorator", mock_my_custom_decorator)
    def test_decorator(self):
        url = reverse("some-api-view")
        response = self.client.post(url)
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json(), {"message": "Success"})
Related