Speed up and rationalise the test suite (158s -> 16s)

Nearly all of the wall clock was password hashing: there was no test-time
PASSWORD_HASHERS override, so Django's PBKDF2 default (~1.2M iterations) ran on
every create_user and every login, hundreds of times over. The fix lives in a
DiscoverRunner subclass wired in via TEST_RUNNER rather than a "test" in
sys.argv sniff in settings: a runner is only ever instantiated by `manage.py
test`, so there is no env var to mis-set and no import path by which a deployed
process can reach the weak hasher. Verified: outside the runner the hasher is
still PBKDF2. It also enables the cached template loader (the runner forces
DEBUG off *after* settings are read, so Django never turns it on by itself) and
silences django.request, whose 4xx/5xx logging buried real test output.

Second, the fixtures. Base classes were rebuilding a club, season, admin user,
membership, role and MFA authenticator once per test; those are read-only for
almost every test, so they move to setUpTestData and are built once per class.
Django hands each test its own deep copy and the per-test transaction rolls the
rows back, so the handful of tests that mutate them stay isolated -- proved with
--shuffle, --reverse and --parallel rather than assumed. Per-test work that
genuinely must stay per-test (client sign-ins, waffle cache clears that leak
across the transaction boundary) is left in setUp with a comment saying why.

Five tests removed, each strictly subsumed by another that asserts a superset;
their intent was folded into a comment on the survivor. Regression-pinning
tests -- the ones carrying comments naming the exact bug they catch -- were
left verbatim throughout.

Also closes a real gap this surfaced: teams had a cross-club position test for
TeamMembership but not for StaffAssignment, with an unused `other_coach`
fixture sitting there waiting for it.

Rejected: --parallel by default (every worker re-runs all 88 migrations, buying
~4s of wall clock for ~5x the CPU), and disabling migrations in tests (~3.5s,
but the schema would then come from models and the suite would stop catching a
broken migration).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 13:48:02 +02:00
parent 581cc81ba7
commit ffe8a3d301
13 changed files with 575 additions and 322 deletions

View File

@@ -19,7 +19,6 @@ from members.models import Family, FamilyMembership, Group, GroupMembership, Mem
from members.services import MemberImportResult
# Create your tests here.
class MemberModelTests(TestCase):
def test_str_and_name_helpers(self):
member = Member.objects.create(first_name="John", last_name="Smith")
@@ -95,17 +94,19 @@ class FamilyNameOptionalTests(TestCase):
class FamilyModelTests(TestCase):
def setUp(self):
self.family = Family.objects.create(name="The Smiths")
self.parent = Member.objects.create(first_name="Pat", last_name="Smith")
self.guardian = Member.objects.create(first_name="Gale", last_name="Smith")
self.child = Member.objects.create(first_name="Kim", last_name="Smith")
self.other = Member.objects.create(first_name="Ola", last_name="Smith")
@classmethod
def setUpTestData(cls):
# One family with a member in every role -- read-only for all four tests.
cls.family = Family.objects.create(name="The Smiths")
cls.parent = Member.objects.create(first_name="Pat", last_name="Smith")
cls.guardian = Member.objects.create(first_name="Gale", last_name="Smith")
cls.child = Member.objects.create(first_name="Kim", last_name="Smith")
cls.other = Member.objects.create(first_name="Ola", last_name="Smith")
FamilyMembership.objects.create(family=self.family, member=self.parent, role=FamilyMembership.FamilyRole.PARENT)
FamilyMembership.objects.create(family=self.family, member=self.guardian, role=FamilyMembership.FamilyRole.GUARDIAN)
FamilyMembership.objects.create(family=self.family, member=self.child, role=FamilyMembership.FamilyRole.CHILD)
FamilyMembership.objects.create(family=self.family, member=self.other, role=FamilyMembership.FamilyRole.OTHER)
FamilyMembership.objects.create(family=cls.family, member=cls.parent, role=FamilyMembership.FamilyRole.PARENT)
FamilyMembership.objects.create(family=cls.family, member=cls.guardian, role=FamilyMembership.FamilyRole.GUARDIAN)
FamilyMembership.objects.create(family=cls.family, member=cls.child, role=FamilyMembership.FamilyRole.CHILD)
FamilyMembership.objects.create(family=cls.family, member=cls.other, role=FamilyMembership.FamilyRole.OTHER)
def test_str(self):
self.assertEqual(str(self.family), "The Smiths")
@@ -210,8 +211,9 @@ class FamilyMembershipModelTests(TestCase):
class GroupModelTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
def test_str_returns_name(self):
group = Group.objects.create(club=self.club, name="Coaches")
@@ -233,10 +235,11 @@ class GroupModelTests(TestCase):
class GroupMembershipModelTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.group = Group.objects.create(club=self.club, name="Referees")
self.member = Member.objects.create(first_name="Ref", last_name="Eree")
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
cls.group = Group.objects.create(club=cls.club, name="Referees")
cls.member = Member.objects.create(first_name="Ref", last_name="Eree")
def test_str(self):
membership = GroupMembership.objects.create(group=self.group, member=self.member)
@@ -285,10 +288,14 @@ class AdminSmokeTests(TestCase):
"""Exercise the admin config end-to-end to catch misregistration
(bad search_fields, autocomplete targets, fieldsets, custom forms)."""
def setUp(self):
self.admin = User.objects.create_superuser(email="root@example.com", password="pw-secret-123")
@classmethod
def setUpTestData(cls):
cls.admin = User.objects.create_superuser(email="root@example.com", password="pw-secret-123")
# Staff must hold a second factor (RequireMFAMiddleware).
Authenticator.objects.create(user=self.admin, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"})
Authenticator.objects.create(user=cls.admin, type=Authenticator.Type.TOTP, data={"secret": "JBSWY3DPEHPK3PXP"})
def setUp(self):
# The test client is per-test, so the sign-in itself cannot be hoisted.
self.client.force_login(self.admin)
def test_changelists_load(self):
@@ -354,13 +361,14 @@ class AdminSmokeTests(TestCase):
class ImportMembersCsvCommandTests(TestCase):
def setUp(self):
@classmethod
def setUpTestData(cls):
# Memberships are season-scoped: the importer attaches each one to the
# club's current season, so the target club needs one covering today.
self.club = Club.objects.create(name="City Swim Club")
cls.club = Club.objects.create(name="City Swim Club")
today = timezone.localdate()
self.season = Season.objects.create(
club=self.club,
cls.season = Season.objects.create(
club=cls.club,
start_date=today - timedelta(days=90),
end_date=today + timedelta(days=275),
)