Testing field validation in Django

2018-02-28 by Senko Rašić

Django comes with a useful set of utilities for testing various aspects of web apps. For example, its TestCase and TransactionTestCase base classes as well as its test client make it easier to test those cases.

However the Django testing utilities can't cover everything, so there are many cases where a robust test involves a lot more manual work than ideal. One of these cases that crops up pretty often is in testing form and model validation, and in particular, asserting exactly which validation errors happen.

Suppose we have the following model:

from django import models

class Person(models.Model):
    name = models.CharField(max_length=100)
    dob = models.DateField()
    website = models.URLField(required=False)

    def clean(self):
        if self.name == 'Joe' and self.website is None:
            raise ValidationError({
                'website': "Joe must have a website"
            })

If we want to test that our custom validation is done correctly, we might try to use assertRaises and do the following:

from django.core.validation import ValidationError
from django.test import TestCase

class PersonTest(TestCase):

    def test_joe_must_have_a_website(self):
        p = Person(name='Joe')
        with self.assertRaises(ValidationError):
            p.full_clean()

This test contains a bug. Can you spot it? The problem is that our model has additional required fields (dob in our case) which we haven't specified in the test, so it will always raise a validation error, even if our code is not correct.

If this was a Form test, we might use assertFormError. For model tests, there's no such shortcut so we're forced to go about it manually:

from django.core.validation import ValidationError
from django.test import TestCase

class PersonTest(TestCase):

    def test_joe_must_have_a_website(self):
        p = Person(name='Joe', dob=datetime.date(1990, 1, 1))

        try:
            p.full_clean()
        except ValidationError as e:
            self.assertTrue('name' in e.message_dict)

If you have a lot of model-level validation, it can be cumbersome to manually use this pattern all the time. Instead, you can extract the pattern into a helper:

class ValidationErrorTestMixin(object):

    @contextmanager
    def assertValidationErrors(self, fields):
        """
        Assert that a validation error is raised, containing all the specified
        fields, and only the specified fields.
        """
        try:
            yield
            raise AssertionError("ValidationError not raised")
        except ValidationError as e:
            self.assertEqual(set(fields), set(e.message_dict.keys()))

You can then use it as:

class PersonTest(ValidationErrorTestMixin, TestCase):

    def test_joe_must_have_a_website(self):
        p = Person(name='Joe', dob=datetime.date(1990, 1, 1))

        with self.assertValidationErrors(self, ['name']):
            p.full_clean()

In cases where you raise validation errors that are not specific to a field, you can check for NON_FIELD_ERRORS:

from django.core.exceptions import NON_FIELD_ERRORS

class Person(models.Model):
    name = models.CharField(max_length=100)
    dob = models.DateField()
    website = models.URLField(required=False)

    def clean(self):
        if self.name == 'Joe' and self.website is None:
            raise ValidationError("Joe must have a website")


class PersonTest(ValidationErrorTestMixin, TestCase):

    def test_joe_must_have_a_website(self):
        p = Person(name='Joe', dob=datetime.date(1990, 1, 1))

        with self.assertValidationErrors(self, [NON_FIELD_ERRORS]):
            p.full_clean()

If you do a lot of model-level validation, extracting the validation pattern into assertValidationError as shown here will make your tests more robust, easier to write, more readable and maintainable.

Author
Senko Rašić
We’re small, experienced and passionate team of web developers, doing custom app development and web consulting.