Unit testing Django admin views

Dani Hodovic Dec. 11, 2022 3 min read
Responsive image

I'm writing this cheat sheet for myself and colleagues who often forget how to write tests for Django admin views. I've seen Django admin use go from a developer and debugging tool to the primary UI for end users. This happens frequently when Django is used to power internal tooling at medium-sized companies where a simple CRUD UI suffices. However, over time the complexity of the admin classes grow to accommodate business requirements and at that point it becomes useful to start testing the functionality of admin to prevent breaking behavior and disrupting user workflows.

The following examples use pytest-style tests instead of Django TestCase-style tests as I use pytest in every single Django project. Pytest-style tests are more succint and offer more powerful developer tooling.

Suppose we're testing an app called 'music' and a model called 'Album'. We'll use a basic ModelAdmin that lists five results per changelist page.

from django.contrib import admin
from myproject.music.models import Album

@admin.register(Album)
class AlbumAdmin(admin.ModelAdmin):
    list_per_page = 5

Pytest-django utilities

The pytest team provides a utility library for writing Django tests. This is especially useful when writing admin tests as it contains a fixture called 'admin_client'. The admin client creates a test user with superuser permissions and authenticates the user with a HTTP client. The fixture also sets up and cleans up the database between each test.

def test_admin_change(admin_client):
    admin_client.get(...)

Testing views

Each ModelAdmin generates five views: changelist, add, change, delete, history.

  • The changelist view lists the model entries. It displays a table with sorting, filtering and search capabilities. The changelist allow for a user to perform actions on a selection of models.
  • The add and change pages submit form data to create or update and individual model instance. They're served by the same logic in Django (def changeform_view).
  • The delete view is intuitive. It triggers a prompt before it performs deletion of one or many model instances. I usually refrain from modifying or testing this view.
  • The history view contains an audit log of all changes performed on a model instance in the admin. I don't modify or test this view either.

URLs for ModelAdmin use the app and the model in the url pattern: admin:<app>_<model>_<action>. Going by our example app:

# Changelist - table view
reverse(admin:music_album_changelist)
# Add an album page
reverse(admin:music_album_add)
# Change an album page
reverse(admin:music_album_change)
# Delete the album
reverse(admin:music_album_delete)
Changelist (table view) tests

Suppose we want to test that our table view renders with two of our album entries. Django uses the ChangeList class to render the table view. The ChangeList exposes a few useful context variables we can make assertions about, such as:

  • result_list - the queryset used to render the page
  • result_count - the total number of objects with admin filters applied
  • full_result_count - the total number of objects without admin filters applied
  • multi_page - indicates that the table has multiple pages

We'll create 20 objects to demonstrate use of all the context variables. As mentioned previously, the ModelAdmin is set to render five objects at a time.

from django.urls import reverse
from myproject.music.models import Album

# The admin client is logged in with a superuser
def test_album_changelist(admin_client):
    albums = [Album(title="Title") for a in range(0, 20)]
    Album.objects.bulk_create(albums)
    url = reverse("admin:music_album_changelist")
    response = admin_client.get(url)
    assert response.status_code == 200

    cl = response.context['cl']
    assert list(cl.result_list) == Album.objects.all()[0:5]
    assert cl.result_count == 20
    assert cl.full_result_count == 20
    assert cl.multi_page == True

TODO: Use filters here and in the model. Modify result_count!

Adding and changing a model

When successfully submitting a form to create a new Album the ModelAdmin view redirects us to the changelist:

def test_album_add(admin_client):
    url = reverse("admin:music_album_add")
    response = admin_client.post(
        url,
        data={"title": "Purple Rain"},
    )
    assert response.status_code == 302
    assert Album.objects.filter(title="Purple Rain").exists()

The behavior in case of errors is unintuitive; the view returns a 200 (instead of 400) and adds details in the form.

Below we'll assert that the form was indeed rendered with an error listing a missing title.

from pytest_django.asserts import assertFormError

def test_album_add(admin_client):
    url = reverse("admin:music_album_add")
    response = admin_client.post(
        url,
        data={},
    )
    assert response.status_code == 200
    assertFormError(response, 'adminform', 'title', 'This field is required.')

The add and change views are served roughly the same logic so the 'add' suffix can be replaced with 'change' above.