⟵back

Testing Django admin methods with PyTest

I've done quite a bit of testing in my career so far as a Django developer, but had never tested its built-in admin interface (just one feature that makes it such a great web development framework). This was because the projects I was working on either did not make use of this interface at all, or the usage of it only involved very basic CRUD operations, so it wasn't really deemed worthy of coverage (debatable).

My main personal project — Named After Women, which I'll write about at length sometime soon — was the ideal use case for making the most of the Django admin to manage my model data. Since I am the only user, I don't have to think about permissions, and I only have to consider my own user needs. I wanted to keep things straightforward and not reinvent the wheel, so for this project I chose not to go with a CMS like Wagtail. (Maybe it would have been a good option if I was using lots of different types of media, or there were several editors using the CMS.)

Django admin methods

Why create Django admin custom methods in the first place? Well, there might be certain information that it's not worth keeping in the database, because it's only ever used in the admin. For example, it doesn't need to be serialised for an API, or passed to a HTML document that uses the Django templating engine to render data.

Below, you can see an example of how the District model looks in the admin list view. We have the name of the district, but what are these other columns? They are not database fields; they are the results of custom methods.

enter image description here

One of my own user requirements was that I needed to be able to see at a glance how I was doing with the number of photos I'd taken, the streets I'd drafted, and the rate at which I'd completed them. That's why I added these custom properties.

In order to build them, I used the integers returned from three model properties that I had defined:

# streets/models.py

from django.db import models


class District(models.Model):

    ...

    @property
    def number_of_added_streets(self):
        return self.streets.count()

    @property
    def number_of_completed_streets(self):
        return self.streets.filter(entry_complete=True).count()

    @property
    def number_of_photos_taken(self):
        return self.streets.filter(image_available=True).count()

Note that one reason I chose to save the above properties to the database, rather build them into the admin, was that I need to access them now and then when using the Django ORM shell. It's quicker to just have them ready as database fields, rather than typing out the respective queries each time.

So, we have number_of_added_streets, number_of_completed_streets, and number_of_photos_taken for the District model. All of them speak for themselves. In the DistrictAdmin, I added the following three custom methods:

# streets/admin.py
def entries_completed(self, obj):
    try:
        divided = (
            obj.number_of_completed_streets / obj.number_of_added_streets
        )
        percentage = divided * 100
        percentage = round(percentage, 2)
        return f"{percentage}%"
    except ZeroDivisionError:
        return "Unknown"


def entries_with_photos_taken(self, obj):
    try:
        divided = (
            obj.number_of_photos_taken / obj.number_of_added_streets
        )
        percentage = divided * 100
        percentage = round(percentage, 2)
        return f"{percentage}%"
    except ZeroDivisionError:
        return "Unknown"


def complete_from_available_photos(self, obj):
    try:
        divided = (
            obj.number_of_completed_streets / obj.number_of_photos_taken
        )
        percentage = divided * 100
        percentage = round(percentage, 2)
        return f"{percentage}%"
    except ZeroDivisionError:
        return "Unknown"

This may look repetitive, I know, but there's no way around it without compromising code clarity. Either way, we achieve each of these properties that can be seen in the admin by doing the necessary divisions, multiplying them by 100, rounding them down to maximum two decimal places, and presenting them as a percentage. The ZeroDivisionError is handled by returning the string "Unknown", because I want to indicate that if this occurs, it's because there have been no streets or photos added for that particular District instance yet.

Testing

I swear by PyTest and Factory Boy to test my Django models.

I won't go too much into the setup required for PyTest here (plenty of tutorials out there for that), but once you've installed both packages, you can create your first factories. These represent mock versions of your models, and you can add as much or as little data as you like:

# tests/factories/streets.py

import factory
from streets.models import District, Street


class DistrictFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = District


class StreetFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Street

This is an example of how we can use them to test our admin methods:

# tests/streets/test_admin.py

import pytest
from tests.factories.streets import DistrictFactory, StreetFactory


def test__district_complete_from_available_photos(admin_client):

    district = DistrictFactory.create(name="Mitte")

    StreetFactory.create_batch(
       3,
       district=district,
       name="Rosa-Luxemburg-Straße",
       eponym_name="Rosa Luxemburg",
       eponym_core_data_added=False,
       entry_complete=True,
       image_available=True,
       tags=["politics", ],
       map_link="https://www.openstreetmap.org/way/109819106",
    )

    StreetFactory.create_batch(
       5,
       district=district,
       name="Rosa-Luxemburg-Straße",
       eponym_name="Rosa Luxemburg",
       eponym_core_data_added=False,
       entry_complete=False,
       image_available=True,
       tags=["politics", ],
       map_link="https://www.openstreetmap.org/way/109819106",
    )

    response = admin_client.get("/admin/streets/district/")

    assert \
        '<td class="field-complete_from_available_photos">37.5%</td>' \
        in str(response.content)

To break it down: first, we've mocked a District instance. This is important not only because we are testing the District admin model, but also because there is a foreign key relation between the District and Street models. In other words, in order to save a new Street instance, it needs to be assigned a District first.

Then, we've created a batch of three Street instances that have district as their District. The fact they have the same name and so on matters little here, because those model fields do not have to be unique. What matters here is some of those streets have entry_complete=True and some entry_complete=False.

The complete_from_available_photos() method involves the number_of_completed_streets divided by the number_of_photos_taken. In our test data, we have 3 completed streets but we have added 8 streets together. 3 divided by 8 x 100 = 0. Therefore, we can expect that the result will be 37.5%.

Using PyTest's admin_client fixture, which gives us access to the admin as a superuser, we fetch the URL for admin page's list of districts (the one in the image I shared earlier on). What we now must do is visit that page — doesn't matter if you're in the test or production environment, though ideally you will have written your tests before deployment 😉

Once you're on the page, right-click on "View Source" (depending on your browser, this may be worded differently). A new tab should open. If you search the page, you should be able to find the name of the method rendered in HTML — in this case, it's <td class="field-entries_completed">, so it's literally data contained within a table.

Since we've already calculated that our expected result is 37.5%, we know that this is the number we should include in our assertion. Note it's important to stringify the response when making the assertion:

assert '<td class="field-complete_from_available_photos">37.5%</td>' in str(response.content)

So, now you can use this pattern to test out your Django admin methods!