Python type hints: How to pass Any
for unused parameters in tests
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:
- the
ModelAdmin
, which represents administration views for a given model - the current Django HTTP request
- 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.
Newly updated: my book Boost Your Django DX now covers Django 5.0 and Python 3.12.
One summary email a week, no spam, I pinky promise.
Related posts: