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()