2

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.

JPG
  • 82,442
  • 19
  • 127
  • 206

4 Answers4

3

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
azundo
  • 5,902
  • 1
  • 14
  • 21
  • Yeah, this indeed works and I was following this method (by separating the main logic from the top-level decorator). – JPG Apr 24 '21 at 04:14
  • btw, I have created [this answer](https://stackoverflow.com/a/67239352/8283848) that has my current setup. Would mind looking at it and giving some suggestions? – JPG Apr 24 '21 at 04:37
  • Yeah, I think yours is cleaner than mine. Also makes me wonder if you can simplify it even further as `my_custom_decorator = lambda func: functools.partial(_my_custom_decorator, func)` (or skip the lambda and use a proper `def` but just trying to fit a one-liner into the comment here). – azundo Apr 24 '21 at 05:01
2

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"})
Lord Elrond
  • 13,430
  • 7
  • 40
  • 80
1

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"})

Aprimus
  • 1,463
  • 1
  • 14
  • 12
  • This works, provided ***the test module has to be initialized before the view module***. Is it possible to control the module loading? – JPG Apr 18 '21 at 14:03
  • Yes and no. If you use reverse() anywhere in declarative scope (in short, not part of a function or method) that is being loaded by Django as part of its startup sequence, Django will load urls and consequently views. You could do a quick `assert 'myapp.decorators' not in sys.modules.keys(), "Oops, something loaded myapp.decorators"` to trace it down. –  Apr 26 '21 at 20:35
-2

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"})
JPG
  • 82,442
  • 19
  • 127
  • 206
  • I haven't studied up on all of the etiquette and rules of SO...but it seems like offering a bounty, having someone take the time to answer, then simply repeating their answer and accepting your own copy is a bit...uncool? – saquintes Apr 27 '21 at 07:56
  • I really appreciate whoever took the time to answer this (or any question in SO), because I know how it feels. If you watch closely, I have [mentioned](https://stackoverflow.com/questions/67144974/how-to-mock-view-decorator-in-django/67239352?noredirect=1#comment118850665_67235190) that this particular answer indicating my current setup/ workaround, which is not ***" exact copy"*** of any of the answers to OP. So, I don't think this is a *"repetition"* of [this](https://stackoverflow.com/a/67235190/8283848) answer. btw, I admit that I didn't mention my workaround in the OP. – JPG Apr 27 '21 at 08:37
  • Also, afaik I can accept any answer to this question, even my own answer (I don't see any issues with that). Also, I will definitely accept any answers that solve the OP's issue with a better approach – JPG Apr 27 '21 at 08:47