How to test a Django ModelForm

Learn how to test a Django ModelForm in isolation in this brief tutorial.

How to test a Django ModelForm

What is a ModelForm in Django?

ModelForm in Django is a convenient abstraction for creating HTML forms tied to Django models.

Consider the following Django model:

from django.db import models
from django.contrib.auth.models import User


class Invoice(models.Model):
    class State(models.TextChoices):
        PAID = "PAID"
        UNPAID = "UNPAID"
        CANCELLED = "CANCELLED"

    user = models.ForeignKey(to=User, on_delete=models.PROTECT)
    date = models.DateField()
    due_date = models.DateField()
    state = models.CharField(max_length=15, choices=State.choices, default=State.UNPAID)

    def __str__(self):
        return self.user.email

To create a form for this model so that we can save and edit invoices in a view, we can subclass ModelForm as follows:

from django import forms
from .models import Invoice


class InvoiceForm(forms.ModelForm):
    class Meta:
        model = Invoice
        fields = ["user", "date", "due_date", "state"]

Here we create an InvoiceForm tied to Invoice. This form will expose the following fields in the form:

  • user
  • date
  • due_date
  • state

Once we create a ModelForm, we can use it in creation/editing Django views. For an example of the usage, check out the documentation. In this post we focus only on testing the form without interacting with the view layer.

(For an example test of a form in the context of a view see Testing an inline formset in Django)

How to test a Django ModelForm

Testing the empty form

When we load our InvoiceForm in a Django create view (which can be a simple function view, or a generic class-based view), the form has nothing to show.

Its only job is to render a series of form fields. In this case, as a simple starter test we can check that the form renders the expected form controls.

Here's an example:

from django.test import TestCase

from billing.forms import InvoiceForm


class TestInvoiceForm(TestCase):
    def test_empty_form(self):
        form = InvoiceForm()
        self.assertInHTML(
            '<input type="text" name="date" required id="id_date">', str(form)
        )
        self.assertInHTML(
            '<input type="text" name="due_date" required id="id_due_date">', str(form)
        )

In this example we instantiate InvoiceForm, and we assert on its HTML (to keep things concise we test just a couple of fields).

This simple test ensures that we don't forget to expose the expected fields in our form. This is also useful when we add custom fields, or for more complex scenarios.

To speed up the test we can also test directly form fields, as in the following example:

from django.test import TestCase

from billing.forms import InvoiceForm


class TestInvoiceForm(TestCase):
    def test_empty_form(self):
        form = InvoiceForm()
        self.assertIn("date", form.fields)
        self.assertIn("due_date", form.fields)

This is useful when you don't care about the rendered HTML. As a personal preference, I always add some assertion on the rendered markup to test the form from the user point of view.

Testing creation and editing

Most of the time, Django forms are not empty. When used in a view, they receive data from an HTTP request. If you use class based views, the machinery of passing data to the form gets handled out of the box by the view.

Here's an example usage of a form in a functional view, stripped down of all the details:

def simple_view(request):
    if request.method == 'POST':
        form = InvoiceForm(request.POST)
        # do stuff
    else:
        # do other stuff

In our tests, we may want to ensure that our form behaves as expected when it gets data from the outside, especially if we customize field rendering or field querysets.

Let's imagine for example that our InvoiceForm should enable the date field only when a staff user reaches the form. Regular users instead must see a disabled date field

To test this behaviour, in our test we prepare a user, and a Django HttpRequest with the appropriate POST data:

from django.test import TestCase
from django.http import HttpRequest
from django.contrib.auth.models import User

from billing.forms import InvoiceForm


class TestInvoiceForm(TestCase):
    def test_empty_form(self):
        # omitted

    def test_it_hides_date_field_for_regular_users(self):
        user = User.objects.create_user(
            username="funny",
            email="just-for-testing@testing.com",
            password="dummy-insecure",
        )
        
        request = HttpRequest()
        request.POST = {
            "user": user.pk,
            "date": "2021-06-03",
            "due_date": "2021-06-03",
            "state": "UNPAID",
        }

        # more in a moment

As for the user model, most projects have a custom model, here we use the stock User from Django.

With the data in place, we pass the request data to InvoiceForm, and this time to keep things simple we assert directly on the field:

from django.test import TestCase
from django.http import HttpRequest
from django.contrib.auth.models import User

from billing.forms import InvoiceForm


class TestInvoiceForm(TestCase):
    def test_empty_form(self):
        # omitted

    def test_it_hides_date_field_for_regular_users(self):
        user = User.objects.create_user(
            username="funny",
            email="just-for-testing@testing.com",
            password="dummy-insecure",
        )

        request = HttpRequest()
        request.POST = {
            "user": user.pk,
            "date": "2021-06-03",
            "due_date": "2021-06-03",
            "state": "UNPAID",
        }

        form = InvoiceForm(request.POST, user=user)
        self.assertTrue(form.fields["date"].disabled)

At this stage, the test will fail because our form cannot handle the keyword argument user.

To fix the test, and the functionality, we override ModelForm __init__() to pop out the user from its arguments, and we disable the date field if the user is not from staff:

from django import forms
from .models import Invoice


class InvoiceForm(forms.ModelForm):
    class Meta:
        model = Invoice
        fields = ["user", "date", "due_date", "state"]

    def __init__(self, *args, **kwargs):
        self.user = kwargs.pop("user", None)
        super().__init__(*args, **kwargs)

        if self.user is not None:
            if not self.user.is_staff:
                self.fields["date"].disabled = True

Since the date input won't be filled by the user, we may want to add a default. This can be done by setting self.fields["date"].initial to something other than an empty value.

To complete the test, we can also save the form, and check that an invoice has been created:

from django.test import TestCase
from django.http import HttpRequest
from django.contrib.auth.models import User

from billing.forms import InvoiceForm
from billing.models import Invoice


class TestInvoiceForm(TestCase):
    def test_empty_form(self):
        # omitted

    def test_it_hides_date_field_for_regular_users(self):
        user = User.objects.create_user(
            username="funny",
            email="just-for-testing@testing.com",
            password="dummy-insecure",
        )

        request = HttpRequest()
        request.POST = {
            "user": user.pk,
            "date": "2021-06-03",
            "due_date": "2021-06-03",
            "state": "UNPAID",
        }

        form = InvoiceForm(request.POST, user=user)
        self.assertTrue(form.fields["date"].disabled)
        form.save()
        self.assertEqual(Invoice.objects.count(), 1)

As the icing on the cake, we can also add a test for a staff to check that everything works as expected. Here's the complete test:

from django.test import TestCase
from django.http import HttpRequest
from django.contrib.auth.models import User

from billing.forms import InvoiceForm
from billing.models import Invoice


class TestInvoiceForm(TestCase):
    def test_empty_form(self):
        form = InvoiceForm()
        self.assertIn("date", form.fields)
        self.assertIn("due_date", form.fields)
        self.assertInHTML(
            '<input type="text" name="date" required id="id_date">', str(form)
        )
        self.assertInHTML(
            '<input type="text" name="due_date" required id="id_due_date">', str(form)
        )

    def test_it_hides_date_field_for_regular_users(self):
        user = User.objects.create_user(
            username="funny",
            email="just-for-testing@testing.com",
            password="dummy-insecure",
        )

        request = HttpRequest()
        request.POST = {
            "user": user.pk,
            "date": "2021-06-03",
            "due_date": "2021-06-03",
            "state": "UNPAID",
        }

        form = InvoiceForm(request.POST, user=user)
        self.assertTrue(form.fields["date"].disabled)
        form.save()
        self.assertEqual(Invoice.objects.count(), 1)

    def test_it_shows_date_field_for_staff_users(self):
        user = User.objects.create_user(
            username="funny",
            email="just-for-testing@testing.com",
            password="dummy-insecure",
            is_staff=True,
        )

        request = HttpRequest()
        request.POST = {
            "user": user.pk,
            "date": "2021-06-03",
            "due_date": "2021-06-03",
            "state": "UNPAID",
        }

        form = InvoiceForm(request.POST, user=user)
        self.assertFalse(form.fields["date"].disabled)
        form.save()
        self.assertEqual(Invoice.objects.count(), 1)

(To avoid duplication, you can move up HttpRequest instantiation to setUpTestData()).

Thanks for reading!

Further resources

Valentino Gagliardi

Hi! I'm Valentino! I'm a freelance consultant with a wealth of experience in the IT industry. I spent the last years as a frontend consultant, providing advice and help, coaching and training on JavaScript, testing, and software development. Let's get in touch!

More from the blog: