Repository navigation
test: make the slowest unit and integration tests fast without weakening them - #1403
Merged
Merged
Conversation
TestOpenAPI403OnPermissionGatedRoutes re-read and regex-scanned all 158 handler_*.go files (about 75k lines) once per router entry, about 130 times. Under -race that took 60-70s, almost all of it in regexp backtracking and GC. Build the set of permission-gated *Handler methods in one pass and look each route up in it. The per-method rule is unchanged, and a temporary comparison against the old function agreed on all 352 Handler methods (99 gated). With every '403' removed from openapi.yaml, main and this branch report the same 50 failing operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TestExecuteAndFinalize_SlowRunGetsFreshTerminalSaveBudget slept 16s in each of two provider calls to outlast the real 30s terminal save budget, so it took 32s. Add a zero-value purchaseBudgets field on Manager that overrides the terminal save and per-rec deadlines when set. NewManager leaves it zero, so production still uses 30s for both; the per-rec literal becomes the named constant perRecPurchaseTimeout with the same value. The test now sets 2s budgets with 1.2s calls and keeps every assertion, scaled. The ExpiredCallerDeadline test already pins the production save and run budgets with literal durations; it now also pins the per-rec cap from below (more than 29s), which the old 16s calls covered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TestNewAzureResolver_GetSecret_BlocksMetadata spent 15-19s in azcore's default retry backoff (3 retries, 800ms base delay, exponential with jitter) on a dial that the guard rejects instantly. Pass the GetSecret call a context carrying azcore's per-call retry override (policy.WithRetryOptions) with a 1ms delay. The resolver is still built by NewAzureResolver unchanged, the same 4 attempts still happen, and every assertion stays. No production change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Login runs a timing-equalizing compare against dummyPasswordHash, a fixed cost-12 hash, for missing, inactive and locked accounts. Under -race one cost-12 compare takes about 7s, so TestLogin_OWASPEnumerationInvariant (4 such paths) took 17-32s and TestIntegration_MFALifecycleWrongCodesLockAccount (2 locked-account logins) about 12s of its run, even though both services already set the existing bcryptCostOverride test hook to MinCost/4. Login now calls dummyHashForCompare. With bcryptCostOverride unset, which is the case for every Service built by NewService, it returns dummyPasswordHash unchanged. Only when a test sets the override does it hash a sentinel at that cost, so the dummy compare costs the same as the test's real hashes. TestDummyHashForCompare_MatchesHashCost pins both cases. TestService_UpdateUserProfile hashed its fixture at DefaultCost in every subtest (7 hashes plus compares, 17-25s). It now hashes once with the existing TestPasswordHash (MinCost) helper and every subtest builds its own User from that hash. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TestApplicationRateLimiter sends 14 logins for a missing account (7 per mode) that reach Login, and each runs the cost-12 timing-equalizing compare: about 5s each under -race, 57-90s in all. The test checks rate-limit counters, not hashing. Add auth.ServiceConfig.BcryptCost, which NewService copies into the existing bcryptCostOverride hook, and an unexported ApplicationConfig.authBcryptCost that both auth.NewService call sites in server pass through. Production never sets either, so zero keeps cost 12 and the cost-12 sentinel; TestDummyHashForCompare_MatchesHashCost now pins that for NewService too. The test sets bcrypt.MinCost and keeps every request and assertion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t cost TestSetupAdminThenLogin_RoundTrip hashes and verifies a password at cost 12 and TestAuthServiceAdapter_Login hits Login's cost-12 timing-equalizing compare for an inactive user, 7-11s under -race in the full suite. Both now set the new auth.ServiceConfig.BcryptCost to bcrypt.MinCost; the round trip still hashes at setup and verifies at login through the real service, so the base64 regression it guards is still covered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Collaborator
Author
|
CI on head d807057 is green (pre-commit run 40160298385 and CI run 40160298330). Job wall times, one sample each:
These are single runs on shared runners, so treat them as rough. The Integration Tests job is still dominated by the migrations package, which this PR does not touch (see "Still slow, not in this PR" in the description). |
IAmJSD
marked this pull request as ready for review
October 25, 2026 10:01
This was referenced Oct 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
On main's CI run 40153816430 (after #1400), the Integration Tests job took 531s and its test step was CPU-bound on a 4-vCPU runner (856s of package time in 480s of wall time). The slowest single tests were:
The untagged ones run twice, once in Unit Tests and once in Integration Tests.
I profiled each test (
-cpuprofile, azcore SDK logging, and per-subtest timings). The time was going to:-race, about 5-7s per operation.Loginruns a timing-equalizing compare againstdummyPasswordHash, a fixed cost-12 hash, for missing, inactive and locked accounts. This happens even in tests that already set the existingbcryptCostOverridehook to MinCost. It accounted for the rate limiter test (14 such logins), the OWASP test (4) and the MFA lockout test (2 locked-account logins). TestService_UpdateUserProfile hashed its fixture at DefaultCost in each of its 7 subtests.handler_*.gofiles (about 75k lines) once per router entry, about 130 times. Its profile was all regexp backtracking and GC.What changed
Every assertion is kept. Production code changes only add an injection point whose default is unchanged. Each change is listed below.
*Handlermethods, then check every route against it. The per-method rule is the same.Manager.budgets purchaseBudgets: a zero-value field that overrides the terminal-save and per-rec deadlines when set.NewManagernever sets it, so production keeps 30s and 30s. The per-rec literal30*time.Secondbecomes the named constantperRecPurchaseTimeout, with the same value. Defaults unchanged.GetSecretcall azcore's existing per-call override,policy.WithRetryOptions, with a 1ms delay. The same 4 attempts still happen, and the resolver is still built byNewAzureResolver.Logincallss.dummyHashForCompare(). WithbcryptCostOverrideunset, it returnsdummyPasswordHashexactly as before. With the existing test hook set, it hashes a sentinel at that cost, so the dummy compare costs the same as the test's real hashes.dummyHashForComparekeys on the existingbcryptCostOverridehook. Default unchanged, and the newTestDummyHashForCompare_MatchesHashCostpins it: aNewServicewithout a cost returnsdummyPasswordHash, at cost 12.TestPasswordHash(MinCost) helper. Each subtest still builds its ownUser.bcrypt.MinCost.auth.ServiceConfig.BcryptCost: zero, the only value production uses, keeps cost 12.NewServicecopies it into the existingbcryptCostOverride.server.ApplicationConfiggets the unexportedauthBcryptCost, passed to bothauth.NewServicecalls inapp.go. Nothing outside the package's tests can set it. Defaults unchanged.BcryptCost: bcrypt.MinCostin the test'sServiceConfig.The purchase change scales this test's budgets. The production values stay pinned with literal durations in
TestExecuteAndFinalize_ExpiredCallerDeadline_UsesOwnBudgets: save in (29s, 30s] and run in (3m59s, 4m]. That test now also pins the per-rec cap from below (more than 29s). The old 16s calls were the only thing covering that bound.Verification
All of this ran on one Linux VM (16 cores, Go 1.26.10, Docker for testcontainers). "main" means a
git archiveof origin/main 8793aa3; "branch" means this PR's head.Before and after
Each line comes from
go test -race [-tags integration] -run '^Name$' -count=1 -v ./internal/<pkg>/, run one test at a time on an otherwise idle machine, main first, then the branch.When an integration test runs alone, its time includes the package's one-off Postgres container and migration setup, about 6-12s here. In a full package run, whichever test runs first pays that. Without the setup, the test's own subtests add up to:
Package totals, run back to back on the same machine:
-race -tags integration, main → branch-race -short, main → branchAuth's unit time drops more than its two named tests account for. Every auth test that hits a failing
Loginwas paying for the cost-12 sentinel.Each test still fails when its behavior breaks
I applied each mutation temporarily to both main and the branch, ran the test, and reverted the change. Every mutation failed on both trees in the same way.
'403'renamed in one gated op ofopenapi.yaml(GET /api/dashboard/summary), the test failed with that op. With every'403':renamed, main and the branch reported the identical set of 50 failing ops (diffed). A throwaway test also compared the old per-route scan with the new index over all 352*Handlermethods: same result for every method, 99 gated.require.NoErrorfailed (save ctx expired). Main failed after 32s, the branch after 2.4s.ExpiredCallerDeadlinefailed.Containsassertion.inactive_accountsubtest failed.wrong_current_passwordsubtest failed.rejectMFACode: 6 identical subtests failed on both.rejectLockedMFAUser: the 3 identical*-locks-after-max-failuressubtests failed.TestDummyHashForCompare_MatchesHashCost: with the default branch ofdummyHashForComparebroken, it failed.Suites and checks
go build ./...,go vet ./...andgo vet -tags integration ./internal/...: clean.go test -race -short -count=1 ./...: all 36 packages with tests pass.go test -race -tags integration -count=1on api, auth, purchase, secrets and server: pass.go test -race -tags integration -json ./...pass, which is how I found the two adapter tests.-tags integration: 0 issues._test.go.pre-commit run --files <changed>: passes. go-test and the frontend hooks were skipped; I ran those suites directly. trivy config (0.69.3, via docker) exit 0.scripts/test-git-secrets-allowlist.sh: 137 pass.Not verified locally
Expected effect
On this machine, the five touched packages drop from about 529s to 215s of integration package time and from about 309s to 82s of
-race -shorttime. Most of it is CPU (bcrypt) and idle waiting. On the CPU-bound 4-vCPU runner, this should show up in both the Unit Tests and Integration Tests jobs.Still slow, not in this PR
These are tests I found still over about 8s when run warm (
-count=2, second iteration):internal/database/postgres/migrations.migrate.go's cost-12bcryptfor the bootstrap admin password, about 7s in "fresh insert with password" and about 4s inTestEnsureAdminUserWithPassword_LogsToStderr_NotStdout. It has no injection point, and a package-level var would race with that package's now-parallel tests. Giving it one would mean changingRunMigrations' signature, so I left it for a separate decision.internal/database/postgres/testhelpers. The ConcurrentFirstUse tests test the shared-server setup itself.Note for cristim
TestExecuteAndFinalize_ExpiredCallerDeadline_UsesOwnBudgets, which now also pins the per-rec cap from below.auth.ServiceConfig.BcryptCostis a new exported field, because the server tests build the auth service throughNewService. Production leaves it at zero. If you'd rather not have an exported knob, the alternative is an exported test-only setter ininternal/auth/test_helpers.goplus a hook inserver. That is more moving parts for the same effect.🤖 Generated with Claude Code