Adam Johnson

Home | Blog | Training | Projects | Colophon | Contact

How to Unit Test a Django Form

2020-06-15 Test this bit, this bit, and this bit

This post is an adapted extract from my book Speed Up Your Django Tests, available now.

Django’s test client is really useful for writing integration tests for your project. It’s great because it has a simple API for testing your application similarly to how a web browser would interact with it. Unfortunately it can be slow, because each call creates a request, passes it through all your middleware, view, maybe a template, then the response comes back through the same layers.

The Test Structure chapter of Speed Up Your Django Tests includes a section on rewriting integration tests that use the test client as unit tests. This rewriting makes them faster and more accurate. Here’s one example rewriting some integration tests for a form as unit tests.

Forms are a great example of a component that can be easily unit tested. They accept a dictionary of values, validate it, and return either errors or cleaned data.

For an example, take this form:

from django import forms

from example.core.models import Book


class AddBookForm(forms.ModelForm):
    class Meta:
        model = Book
        fields = ["title"]

    def clean_title(self):
        title = self.cleaned_data["title"]
        if not title:
            return title

        if not title[0].isupper():
            self.add_error("title", "Should start with an uppercase letter")

        if title.endswith("."):
            self.add_error("title", "Should not end with a full stop")

        if "&" in title:
            self.add_error("title", "Use 'and' instead of '&'")

        return title

It has a few validation steps for the title field that we’d like to test in isolation.

For reference, here’s the corresponding view:

from django.shortcuts import redirect, render

from example.core.forms import AddBookForm


def add_book(request):
    if request.method == "POST":
        form = AddBookForm(request.POST)
        if form.is_valid():
            form.save()
            return redirect("/books/")
    else:
        form = AddBookForm()
    return render(request, "add_book.html", {"form": form})

Integration Tests

You can write integration tests for the form with the test client, checking for error messages in the responses’ HTML:

from http import HTTPStatus

from django.test import TestCase


class AddBookFormTests(TestCase):
    def test_title_starting_lowercase(self):
        response = self.client.post(
            "/books/add/", data={"title": "a lowercase title"}
        )

        self.assertEqual(response.status_code, HTTPStatus.OK)
        self.assertContains(
            response, "Should start with an uppercase letter", html=True
        )

    def test_title_ending_full_stop(self):
        response = self.client.post(
            "/books/add/", data={"title": "A stopped title."}
        )

        self.assertEqual(response.status_code, HTTPStatus.OK)
        self.assertContains(
            response, "Should not end with a full stop", html=True
        )

    def test_title_with_ampersand(self):
        response = self.client.post(
            "/books/add/", data={"title": "Dombey & Son"}
        )

        self.assertEqual(response.status_code, HTTPStatus.OK)
        self.assertContains(response, "Use 'and' instead of '&'", html=True)

These tests work, but they have two flaws.

First, they have all of that integration test overhead. To check these error messages, we don’t really care about the details of HTTP or HTML. But here we have to check HTTP status codes and parse HTML with assertContains(..., html=True) in every test.

Second, they’re imprecise. The assertContains() calls check for error messages somewhere in the output, rather than directly related to the title field. If we had two fields with similar validation logic, these tests could accidentally pass because we used bad test data for the other field. We could rewrite the tests to inspect for a more precise HTML string, but that would couple them further to the details of form rendering.

Unit Tests

You can instead test the form directly:

from django.test import TestCase

from example.core.forms import AddBookForm


class AddBookFormTests(TestCase):
    def test_title_starting_lowercase(self):
        form = AddBookForm(data={"title": "a lowercase title"})

        self.assertEqual(
            form.errors["title"], ["Should start with an uppercase letter"]
        )

    def test_title_ending_full_stop(self):
        form = AddBookForm(data={"title": "A stopped title."})

        self.assertEqual(
            form.errors["title"], ["Should not end with a full stop"]
        )

    def test_title_with_ampersand(self):
        form = AddBookForm(data={"title": "Dombey & Son"})

        self.assertEqual(form.errors["title"], ["Use 'and' instead of '&'"])

These tests correct the two flaws. They’re faster because they simply pass in and read out dictionaries, with no need to touch anything related to HTTP or HTML. And they’re more precise because they directly inspect the errors for “title,” ignoring the other fields.

Note you’d still want to have some integration tests, to check that the view, form, and template work together:

from http import HTTPStatus

from django.test import TestCase


class AddBookViewTests(TestCase):
    def test_get(self):
        response = self.client.get("/books/add/")

        self.assertEqual(response.status_code, HTTPStatus.OK)
        self.assertContains(response, "<h1>Add Book</h1>", html=True)

    def test_post_success(self):
        response = self.client.post(
            "/books/add/", data={"title": "Dombey and Son"}
        )

        self.assertEqual(response.status_code, HTTPStatus.FOUND)
        self.assertEqual(response["Location"], "/books/")

    def test_post_error(self):
        response = self.client.post(
            "/books/add/", data={"title": "Dombey & Son"}
        )

        self.assertEqual(response.status_code, HTTPStatus.OK)
        self.assertContains(response, "Use 'and' instead of '&'", html=True)

Given that the form is already fully tested, these view tests are sufficient as they provide full coverage of the only three paths through the view.

Fin

I hope this helps you write faster, more targeted tests,

—Adam


Are your Django project's tests slow? Read Speed Up Your Django Tests now!


Subscribe via RSS, Twitter, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: django, python