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!