Django: speed up tests slightly by disabling update_last_login

A little seed of an idea.

Django’s test client provides two methods to log in a user: login() and force_login(). The latter one is faster because it bypasses the authentication backend, including password hashing, and just sets a session to have the user logged in. Typically, you’d want to use it in setUp() like this:

from http import HTTPStatus

from django.test import TestCase


class CornCobListViewTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = User.objects.create_user(
            username="testuser",
            password="12345",
        )

    def setUp(self):
        self.client.force_login(self.user)

    def test_success(self):
        response = self.client.get("/cobs/")

        assert response.status_code == HTTPStatus.OK
        ...

When profiling some client tests last week, I spotted that force_login() still took several milliseconds. Drilling down revealed that there was one query to create the session and another to update the user’s last_login field.

This update comes from the update_last_login() function (source):

def update_last_login(sender, user, **kwargs):
    """
    A signal receiver which updates the last_login date for
    the user logging in.
    """
    user.last_login = timezone.now()
    user.save(update_fields=["last_login"])

This receiver is conditionally registered in the django.contrib.auth app config (source):

class AuthConfig(AppConfig):
    ...

    def ready(self):
        ...
        # Register the handler only if UserModel.last_login is a field.
        if isinstance(last_login_field, DeferredAttribute):
            from .models import update_last_login

            user_logged_in.connect(update_last_login, dispatch_uid="update_last_login")
        ...

Well, an extra query per force_login() is not a big deal, but with several thousand tests, it adds up to several seconds of the test suite’s ~8 minute runtime. Since the project in question does not use last_login, I decided to disconnect the signal receiver during tests with a session-scoped pytest fixture:

import pytest


@pytest.fixture(scope="session", autouse=True)
def disable_update_last_login():
    """
    Disable the update_last_login signal receiver to reduce login overhead.

    See: https://adamj.eu/tech/2024/09/18/django-test-speed-last-login/
    """
    user_logged_in.disconnect(dispatch_uid="update_last_login")
    yield

In projects using Django’s test framework, you can use the same approach inside a custom test runner.

So yeah, this change provides a small constant saving, but it is easy to achieve. A better solution would be to drop last_login from the custom user model, which would stop the receiver being registered, but that’s certainly more involved.

Fin

Every little helps,

—Adam


😸😸😸 Check out my new book on using GitHub effectively, Boost Your GitHub DX! 😸😸😸


Subscribe via RSS, Twitter, Mastodon, or email:

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

Related posts:

Tags: