How to unit-test a django-form

Introduction

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.

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)

Fin

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

Adam