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

@@ -26,13 +26,18 @@ from .services.reminders import admin_emails, reminders_to_send, send_reminder
class BillingTestBase(TestCase):
def setUp(self):
self.today = timezone.localdate()
self.club = Club.objects.create(name="Ajax United")
self.plan = Plan.objects.create(name="Standard")
# setUpTestData, not setUp: the club and the priced plan are read-only scaffolding for
# every subclass, so they are built once per class. Django hands each test its own deep
# copy and rolls the database back afterwards, so the tests that archive the club or
# soft-delete the plan still start from a clean slate.
@classmethod
def setUpTestData(cls):
cls.today = timezone.localdate()
cls.club = Club.objects.create(name="Ajax United")
cls.plan = Plan.objects.create(name="Standard")
# Priced well back, so a backdated (lapsed) period still has a price in force —
# opening one before any price existed is refused, and rightly so.
PlanPrice.objects.create(plan=self.plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
PlanPrice.objects.create(plan=cls.plan, active_from=cls.today - datetime.timedelta(days=1200), amount=Decimal("500.00"))
def bill(self, start=None, club=None):
return open_period(club or self.club, start=start, plan=self.plan)
@@ -116,9 +121,10 @@ class PeriodTests(BillingTestBase):
class PaymentTests(BillingTestBase):
def setUp(self):
super().setUp()
self.due = self.bill()
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.due = open_period(cls.club, plan=cls.plan)
def test_a_part_payment_leaves_the_due_partially_paid(self):
record_payment(self.due, Decimal("200.00"))
@@ -243,9 +249,10 @@ class GraceAndArchiveTests(BillingTestBase):
class ArchiveCommandTests(BillingTestBase):
def setUp(self):
super().setUp()
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=DEFAULT_GRACE_DAYS + 10))
@classmethod
def setUpTestData(cls):
super().setUpTestData()
subscribe(cls.club, cls.plan, start=cls.today - datetime.timedelta(days=DEFAULT_GRACE_DAYS + 10))
def run_command(self, *args):
out = StringIO()
@@ -275,13 +282,14 @@ class ArchiveCommandTests(BillingTestBase):
class ReactivationTests(BillingTestBase):
def setUp(self):
super().setUp()
@classmethod
def setUpTestData(cls):
super().setUpTestData()
# Through subscribe(), not open_period(): reactivating reads the club's plan off its
# subscription, and a club billed without one cannot be re-billed later.
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=400))
self.first = self.club.dues.first()
self.club.archive()
subscribe(cls.club, cls.plan, start=cls.today - datetime.timedelta(days=400))
cls.first = cls.club.dues.first()
cls.club.archive()
def test_reactivating_continues_from_the_lapsed_period_by_default(self):
due = reactivate(self.club)
@@ -531,12 +539,13 @@ class TrialTests(BillingTestBase):
see billing.services.dues.start_trial and the trial-conversion check in
open_period()."""
def setUp(self):
super().setUp()
@classmethod
def setUpTestData(cls):
super().setUpTestData()
# A trial is a plan whose own duration_months IS the trial length -- there is no
# trial_months argument any more.
self.trial_plan = Plan.objects.create(name="Trial", duration_months=2, is_trial=True, grace_days=14, renewal_lead_days=7)
PlanPrice.objects.create(plan=self.trial_plan, active_from=self.today - datetime.timedelta(days=1200), amount=Decimal("50.00"))
cls.trial_plan = Plan.objects.create(name="Trial", duration_months=2, is_trial=True, grace_days=14, renewal_lead_days=7)
PlanPrice.objects.create(plan=cls.trial_plan, active_from=cls.today - datetime.timedelta(days=1200), amount=Decimal("50.00"))
def test_start_trial_creates_a_short_trial_period(self):
due = start_trial(self.club, self.trial_plan, post_trial_plan=self.plan)
@@ -753,13 +762,14 @@ class BillingReminderTests(BillingTestBase):
"""Reminder emails -- see billing/services/reminders.py. Sent once per escalation
level, because the command is on a daily cron."""
def setUp(self):
super().setUp()
@classmethod
def setUpTestData(cls):
super().setUpTestData()
user = User.objects.create_user(email="admin@ajax.example", password="pw-secret-123")
member = Member.objects.create(user=user, first_name="Ada", last_name="Admin")
ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.ADMIN)
subscribe(self.club, self.plan, start=self.today - datetime.timedelta(days=1))
self.due = self.club.dues.first()
ClubRole.objects.create(club=cls.club, member=member, role=ClubRole.Roles.ADMIN)
subscribe(cls.club, cls.plan, start=cls.today - datetime.timedelta(days=1))
cls.due = cls.club.dues.first()
def test_a_reminder_goes_to_the_club_admins(self):
self.assertEqual(admin_emails(self.club), ["admin@ajax.example"])