Python type hints: How to pass Any for unused parameters in tests

Wibbly wobbley typey wipey stuff.

When you create a function to match an interface, it often needs to accept parameters that it doesn’t use. Once you introduce type hints, testing such functions can become a little irksome as Mypy will require all arguments to have the correct types. Your tests can end up creating unused objects only to match the tested function’s signature. Here’s a technique to avoid that work.

As an example, take Django admin action functions. These are called by Django’s admin interface when the user selects them. Django passes admin functions three arguments:

  1. the ModelAdmin, which represents administration views for a given model
  2. the current Django HTTP request
  3. a QuerySet of the objects to act on.

With Mypy and django-stubs set up, you can create a fully typed action function like so:

from django.contrib import admin
from django.db.models import QuerySet
from django.http import HttpRequest

from example.models import Book
from example.models import normalize_title


@admin.action()
def normalize_titles(
    modeladmin: admin.ModelAdmin[Book],
    request: HttpRequest,
    queryset: QuerySet[Book],
) -> None:
    for book in queryset:
        book.title = normalize_title(book.title)
    Book.objects.bulk_update(queryset, ["title"])


@admin.register(Book)
class BookAdmin(admin.ModelAdmin[Book]):
    actions = [normalize_titles]

And here’s a complete test for the action function:

from django.contrib import admin
from django.test import RequestFactory
from django.test import TestCase

from example.admin import BookAdmin
from example.admin import normalize_titles
from example.models import Book


class NormalizeTitlesTests(TestCase):
    def test_one(self):
        book = Book.objects.create(title="waybound")
        modeladmin = BookAdmin(Book, admin.site)
        request = RequestFactory().get("/admin/example/book/")

        normalize_titles(
            modeladmin,
            request,
            Book.objects.all(),
        )

        book.refresh_from_db()
        assert book.title == "Waybound"

This test works but it’s a little bit long. Both modeladmin and request are unused by normalize_titles(), so the work to create them is wasted, slowing down the writing, reading, and running of the test. It would be preferable to avoid creating these unused objects.

(Creating unused objects could be even slower if they require external services, such as database access.)

One option is to modify normalize_titles() to type the unused parameters as Any:

from typing import Any

...


@admin.action()
def normalize_titles(
    modeladmin: Any,
    request: Any,
    queryset: QuerySet[Book],
) -> None:
    ...

Then tests could then pass in any dummy value, like None. This technique would work, but it removes key information from the signature and would be inconsistent with other action functions. It’s also not possible if you enable Mypy’s disallow_any_explicit option, which bans Any in signatures.

Better is to keep the strict types but write tests that use an Any type. You can do this by creating a variable with type Any and value None, and passing that for the unused parameters:

from typing import Any

from django.test import TestCase

from example.admin import normalize_titles
from example.models import Book

anyvalue: Any = None


class NormalizeTitlesTests(TestCase):
    def test_one(self):
        book = Book.objects.create(title="waybound")

        normalize_titles(anyvalue, anyvalue, Book.objects.all())

        book.refresh_from_db()
        assert book.title == "Waybound"

There we go. The test is shorter and faster, and Mypy reports no issues. If the action function changes to use modeladmin or request, the test will almost certainly fail because None won’t work appropriately.

(Though, if you do have disallow_any_explicit enabled, you’ll need it disabled for your test modules.)

Any cannot be instantiated

You might wonder why it’s necessary to create anyvalue, rather than creating Any objects:

normalize_titles(Any(), Any(), Book.objects.all())

Well, that’s because Any blocks instantiation:

>>> from typing import Any
>>> Any()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/.../python3.11/typing.py", line 514, in __new__
    raise TypeError("Any cannot be instantiated")
TypeError: Any cannot be instantiated

This is pretty sensible, since it’s not clear what an Any should act like at runtime. Better to use something like None that will fail most operations.

Probably avoid mock.ANY here

When I started writing this post, I thought using mock.ANY for unused arguments was a good idea. It’s a singleton object in the standard library that has type Any. But it has more runtime behaviour than None, with special behaviour to compare equal to all objects. This “magic” could lead to tests passing accidentally when the function changes to use a parameter, so I think it’s better to avoid.

Fin

I hope you’ve learned something, Anything, from this post,

—Adam


Newly updated: my book Boost Your Django DX now covers Django 5.0 and Python 3.12.


Subscribe via RSS, Twitter, Mastodon, or email:

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

Related posts:

Tags: ,