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

@@ -13,17 +13,21 @@ from .models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Tea
class TeamsTestCase(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United", slug="ajax-united")
self.season = Season.objects.create(
club=self.club,
# Shared read-only scaffolding for every teams test. The few that delete a fixture
# (member, team, season) get a per-test copy from setUpTestData and the rows come
# back with the transaction rollback.
@classmethod
def setUpTestData(cls):
cls.club = Club.objects.create(name="Ajax United", slug="ajax-united")
cls.season = Season.objects.create(
club=cls.club,
start_date=datetime.date(2026, 8, 1),
end_date=datetime.date(2027, 5, 31),
)
self.team = Team.objects.create(club=self.club, name="First Team", short_name="1st")
self.forward = Position.objects.create(club=self.club, name="Forward", short_name="FW")
self.coach = Position.objects.create(club=self.club, name="Head Coach", short_name="HC", staff_position=True)
self.member = Member.objects.create(first_name="Jane", last_name="Doe")
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
cls.forward = Position.objects.create(club=cls.club, name="Forward", short_name="FW")
cls.coach = Position.objects.create(club=cls.club, name="Head Coach", short_name="HC", staff_position=True)
cls.member = Member.objects.create(first_name="Jane", last_name="Doe")
class TeamModelTests(TeamsTestCase):
@@ -127,12 +131,13 @@ class StaffAssignmentModelTests(TeamsTestCase):
class RosterCleanTests(TeamsTestCase):
def setUp(self):
super().setUp()
self.other = Club.objects.create(name="Rival FC", slug="rival-fc")
self.other_season = Season.objects.create(club=self.other, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31))
self.other_position = Position.objects.create(club=self.other, name="Forward", short_name="FW")
self.other_coach = Position.objects.create(club=self.other, name="Coach", short_name="C", staff_position=True)
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.other = Club.objects.create(name="Rival FC", slug="rival-fc")
cls.other_season = Season.objects.create(club=cls.other, start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2027, 5, 31))
cls.other_position = Position.objects.create(club=cls.other, name="Forward", short_name="FW")
cls.other_coach = Position.objects.create(club=cls.other, name="Coach", short_name="C", staff_position=True)
def test_teammembership_rejects_cross_club_season(self):
entry = TeamMembership(team=self.team, member=self.member, season=self.other_season, position=self.forward)
@@ -155,6 +160,15 @@ class RosterCleanTests(TeamsTestCase):
assignment.full_clean()
self.assertIn("season", ctx.exception.error_dict)
def test_staffassignment_rejects_cross_club_position(self):
# The StaffAssignment half of test_teammembership_rejects_cross_club_position:
# clean() validates `position` as well as `season` against the team's club, and
# nothing exercised that branch -- `other_coach` was sitting unused waiting for it.
assignment = StaffAssignment(team=self.team, member=self.member, season=self.season, position=self.other_coach)
with self.assertRaises(ValidationError) as ctx:
assignment.full_clean()
self.assertIn("position", ctx.exception.error_dict)
def test_staffassignment_accepts_same_club(self):
StaffAssignment(team=self.team, member=self.member, season=self.season, position=self.coach).full_clean()
@@ -233,10 +247,11 @@ class RefereeLevelModelTests(TeamsTestCase):
class RefereeProfileModelTests(TeamsTestCase):
def setUp(self):
super().setUp()
self.level = RefereeLevel.objects.create(club=self.club, name="Regional")
self.level.teams.add(self.team)
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.level = RefereeLevel.objects.create(club=cls.club, name="Regional")
cls.level.teams.add(cls.team)
def test_str(self):
profile = RefereeProfile.objects.create(member=self.member)