unlockme
unlockme

Reputation: 4255

Mock out a model field validator in Django

According to the documentation of the python mock library. We mock out a function from the module in which it is being used/called.

a.py
def function_to_mock(x):
   print('Called function to mock')

b.py
from a import function_to_mock

def function_to_test(some_param):
    function_to_mock(some_param)

# According to the documentation 
#if we want to mock out function_to_mock in a
# test we have to patch it from the b.py module because that is where 
# it is called from

class TestFunctionToTest(TestCase):

    @patch('b.function_to_mock')
    def test_function_to_test(self, mock_for_function_to_mock):
        function_to_test()
        mock_for_function_to_mock.assert_called_once()
   
# this should mock out function to mock and the assertion should work

I got myself in a situation where I can't tell exactly how to mock the function in question. Here is the situation.

# some application
validators.py
def validate_a_field(value):
    # do your validation here.

models.py
from .validators import validate_a_field

class ModelClass(models.Model):
      a_field = models.CharField(max_length=25, validators=[validate_a_field])

forms.py
class ModelClassModelForm(forms.ModelForm):
      class Meta:
           model = ModelClass
           fields = ['a_field',]

Finally in my tests.py
tests.py

class TestModelClassModelForm(TestCase):
      @patch('models.validate_a_field') <<< What to put here ???
      def test_valid_data_validates(self, validate_a_field_mock):
           data = {'a_field':'Some Text'}
           validate_a_field_mock.return_value = True

           form = ModelClassModelForm(data=data)
           is_valid = form.is_valid()
           validate_a_field_mock.assert_called_once() <<< This is failing

From my little understanding even though validate_a_field is called in models.py. It is never used from there when validation takes place. It, therefore, is not mocked when I patch as models.validate_a_field

My best guess is that it is called somewhere in django.forms.field. But I don't know how or where.

Does anyone know how to resolve this conundrum? I do have to mock out validate_a_field because it does call external APIs which is more of an integration test. I want to write a unit test.

Upvotes: 11

Views: 1448

Answers (2)

Don Kirkby
Don Kirkby

Reputation: 56230

The problem is that your model already took a copy of the validate_a_field() function by the time you mock it, so the model still calls the original. That copy isn't exposed anywhere that I know of, so I would add a wrapper function, just to allow mocking. Make validate_a_field() just a wrapper for the real validation code, then mock the inner function.

# validators.py
def validate_a_field(value):
    # Delegate to allow testing.
    really_validate_a_field(value)

def really_validate_a_field(value):
    # do your validation here.
# tests.py
class TestModelClassModelForm(TestCase):
      @patch('validators.really_validate_a_field')
      def test_valid_data_validates(self, validate_a_field_mock):
           data = {'a_field':'Some Text'}
           validate_a_field_mock.return_value = True

           form = ModelClassModelForm(data=data)
           is_valid = form.is_valid()
           validate_a_field_mock.assert_called_once()

Here's a complete, runnable example for you to play with. The files are all mushed into one, but you can run it on its own to see how everything works.

When you run it, test_patch_outer() fails, and test_patch_inner() passes.

""" A Django web app and unit tests in a single file.

Based on Nsukami's blog post: https://nskm.xyz/posts/dsfp/

To get it running, copy it into a directory named udjango:
$ pip install django
$ python udjango_test.py

Change the DJANGO_COMMAND to runserver to switch back to web server.

Tested with Django 4.0 and Python 3.9.
"""


import os
import sys
from unittest.mock import patch

import django
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib import admin
from django.core.management import call_command
from django.core.management.utils import get_random_secret_key
from django.core.wsgi import get_wsgi_application
from django import forms
from django.db import models
from django.db.models.base import ModelBase
from django.test import TestCase

WIPE_DATABASE = True
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_FILE = os.path.join(BASE_DIR, 'udjango.db')
DJANGO_COMMAND = 'test'  # 'test' or 'runserver'

# the current folder name will also be our app
APP_LABEL = os.path.basename(BASE_DIR)
urlpatterns = []
ModelClass = ModelClassModelForm = None


class Tests(TestCase):
    @patch('__main__.validate_a_field')
    def test_patch_outer(self, validate_a_field_mock):
        data = {'a_field':'Some Text'}
        validate_a_field_mock.return_value = True

        form = ModelClassModelForm(data=data)
        is_valid = form.is_valid()
        validate_a_field_mock.assert_called_once()

    @patch('__main__.really_validate_a_field')
    def test_patch_inner(self, validate_a_field_mock):
        data = {'a_field':'Some Text'}
        validate_a_field_mock.return_value = True

        form = ModelClassModelForm(data=data)
        is_valid = form.is_valid()
        validate_a_field_mock.assert_called_once()

def validate_a_field(value):
    really_validate_a_field(value)

def really_validate_a_field(value):
    # do your validation here.
    raise RuntimeError('External dependency not available.')

def main():
    global ModelClass, ModelClassModelForm
    setup()

    # Create your models here.
    class ModelClass(models.Model):
        a_field = models.CharField(max_length=25, validators=[validate_a_field])

    class ModelClassModelForm(forms.ModelForm):
        class Meta:
            model = ModelClass
            fields = ['a_field',]

    admin.site.register(ModelClass)
    admin.autodiscover()

    if __name__ == "__main__":
        if DJANGO_COMMAND == 'test':
            call_command('test', '__main__.Tests')
        else:
            if WIPE_DATABASE or not os.path.exists(DB_FILE):
                with open(DB_FILE, 'w'):
                    pass
                call_command('makemigrations', APP_LABEL)
                call_command('migrate')
                get_user_model().objects.create_superuser('admin', '', 'admin')
            call_command(DJANGO_COMMAND)
    else:
        get_wsgi_application()


def setup():
    sys.path[0] = os.path.dirname(BASE_DIR)

    static_path = os.path.join(BASE_DIR, "static")
    try:
        os.mkdir(static_path)
    except FileExistsError:
        pass
    settings.configure(
        DEBUG=True,
        ROOT_URLCONF=__name__,
        MIDDLEWARE=[
            'django.middleware.security.SecurityMiddleware',
            'django.contrib.sessions.middleware.SessionMiddleware',
            'django.middleware.common.CommonMiddleware',
            'django.middleware.csrf.CsrfViewMiddleware',
            'django.contrib.auth.middleware.AuthenticationMiddleware',
            'django.contrib.messages.middleware.MessageMiddleware',
            'django.middleware.clickjacking.XFrameOptionsMiddleware',
            'django.middleware.locale.LocaleMiddleware',
            ],
        INSTALLED_APPS=[
            APP_LABEL,
            'django.contrib.admin',
            'django.contrib.auth',
            'django.contrib.contenttypes',
            'django.contrib.sessions',
            'django.contrib.messages',
            'django.contrib.staticfiles',
            'rest_framework',
            ],
        STATIC_URL='/static/',
        STATICFILES_DIRS=[
            static_path,
        ],
        STATIC_ROOT=os.path.join(BASE_DIR, "static_root"),
        MEDIA_ROOT=os.path.join(BASE_DIR, "media"),
        MEDIA_URL='/media/',
        SECRET_KEY=get_random_secret_key(),
        DEFAULT_AUTO_FIELD='django.db.models.AutoField',
        TEMPLATES=[
            {
                'BACKEND': 'django.template.backends.django.DjangoTemplates',
                'DIRS': [os.path.join(BASE_DIR, "templates")],
                'APP_DIRS': True,
                'OPTIONS': {
                    'context_processors': [
                        'django.template.context_processors.debug',
                        'django.template.context_processors.i18n',
                        'django.template.context_processors.request',
                        'django.contrib.auth.context_processors.auth',
                        'django.template.context_processors.tz',
                        'django.contrib.messages.context_processors.messages',
                    ],
                },
            },
            ],
        DATABASES={
            'default': {
                'ENGINE': 'django.db.backends.sqlite3',
                'NAME': DB_FILE,
                }
            },
        REST_FRAMEWORK={
            'DEFAULT_PERMISSION_CLASSES': [
                'rest_framework.permissions.IsAdminUser',
            ],
        }
    )

    django.setup()
    app_config = django.apps.apps.app_configs[APP_LABEL]
    app_config.models_module = app_config.models
    original_new_func = ModelBase.__new__

    @staticmethod
    def patched_new(cls, name, bases, attrs):
        if 'Meta' not in attrs:
            class Meta:
                app_label = APP_LABEL
            attrs['Meta'] = Meta
        return original_new_func(cls, name, bases, attrs)
    ModelBase.__new__ = patched_new


main()

Upvotes: 2

JPG
JPG

Reputation: 88659

If I have a choice, I would patch the ModelClass.full_clean method

class TestModelClassModelForm(TestCase):

    @patch('sample.models.ModelClass.full_clean')
    def test_valid_data_validates(self, mock_model_full_clean):
        data = {'a_field': 'Some Text'}
        form = ModelClassModelForm(data=data)
        is_valid = form.is_valid()

        mock_model_full_clean.assert_called_once()
        self.assertTrue(is_valid)
        self.assertDictEqual(data, form.cleaned_data)

Upvotes: 0

Related Questions