Skip to content

test: make the slowest unit and integration tests fast without weakening them - #1403

Merged
IAmJSD merged 7 commits into
LeanerCloud:mainfrom
IAmJSD:test/fast-slow-tests
Oct 25, 2026
Merged

IAmJSD merged 7 commits into
LeanerCloud:mainfrom
IAmJSD:test/fast-slow-tests

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator

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:

Test CI time Kind
TestOpenAPI403OnPermissionGatedRoutes 60s api, untagged
TestApplicationRateLimiter 57s server, integration
TestExecuteAndFinalize_SlowRunGetsFreshTerminalSaveBudget 32s purchase, untagged
TestMigrations_DownAllRoundTrip 21s migrations, integration
TestNewAzureResolver_GetSecret_BlocksMetadata 19s secrets, untagged
TestLogin_OWASPEnumerationInvariant 17s auth, untagged
TestService_UpdateUserProfile 17s auth, untagged
TestIntegration_MFALifecycleWrongCodesLockAccount 16s auth, integration

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:

  • bcrypt at cost 12 under -race, about 5-7s per operation. Login runs a timing-equalizing compare against dummyPasswordHash, a fixed cost-12 hash, for missing, inactive and locked accounts. This happens even in tests that already set the existing bcryptCostOverride hook 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.
  • Real waiting on production timeouts. The purchase test made two 16s provider calls to outlast the real 30s terminal-save budget. The Azure test sat through azcore's default retry backoff (3 retries, 800ms base, exponential): 0.9s + 2.1s + 6.3s per subtest, for a dial the guard rejects in microseconds.
  • Repeated work. The OpenAPI guard re-read and regex-scanned all 158 handler_*.go files (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.

Test Fix Production change
TestOpenAPI403OnPermissionGatedRoutes Scan the handler files once into a set of gated *Handler methods, then check every route against it. The per-method rule is the same. None
TestExecuteAndFinalize_SlowRunGetsFreshTerminalSaveBudget Use 2s budgets with 1.2s calls instead of 30s budgets with 16s calls. Every assertion is the same, scaled. Manager.budgets purchaseBudgets: a zero-value field that overrides the terminal-save and per-rec deadlines when set. NewManager never sets it, so production keeps 30s and 30s. The per-rec literal 30*time.Second becomes the named constant perRecPurchaseTimeout, with the same value. Defaults unchanged.
TestNewAzureResolver_GetSecret_BlocksMetadata Pass the GetSecret call azcore's existing per-call override, policy.WithRetryOptions, with a 1ms delay. The same 4 attempts still happen, and the resolver is still built by NewAzureResolver. None
TestLogin_OWASPEnumerationInvariant, TestIntegration_MFALifecycleWrongCodesLockAccount Login calls s.dummyHashForCompare(). With bcryptCostOverride unset, it returns dummyPasswordHash exactly 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. dummyHashForCompare keys on the existing bcryptCostOverride hook. Default unchanged, and the new TestDummyHashForCompare_MatchesHashCost pins it: a NewService without a cost returns dummyPasswordHash, at cost 12.
TestService_UpdateUserProfile Hash the fixture once with the existing TestPasswordHash (MinCost) helper. Each subtest still builds its own User. None
TestApplicationRateLimiter Build the app's auth service at bcrypt.MinCost. auth.ServiceConfig.BcryptCost: zero, the only value production uses, keeps cost 12. NewService copies it into the existing bcryptCostOverride. server.ApplicationConfig gets the unexported authBcryptCost, passed to both auth.NewService calls in app.go. Nothing outside the package's tests can set it. Defaults unchanged.
TestSetupAdminThenLogin_RoundTrip, TestAuthServiceAdapter_Login (found by the full-suite sweep, 7-11s) Set BcryptCost: bcrypt.MinCost in the test's ServiceConfig. None beyond the field above

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 archive of 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.

Test main branch
TestOpenAPI403OnPermissionGatedRoutes 44.55s 0.73s
TestApplicationRateLimiter (integration) 68.35s 9.55s
TestExecuteAndFinalize_SlowRunGetsFreshTerminalSaveBudget 32.02s 2.41s
TestNewAzureResolver_GetSecret_BlocksMetadata 17.78s 0.01s
TestLogin_OWASPEnumerationInvariant 17.44s 0.24s
TestService_UpdateUserProfile 14.15s 0.18s
TestIntegration_MFALifecycleWrongCodesLockAccount (integration) 19.07s 10.77s
TestSetupAdminThenLogin_RoundTrip 8.67s 0.04s
TestAuthServiceAdapter_Login 4.40s 0.04s

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:

  • TestApplicationRateLimiter: 76s (35.3s + 41.1s) on main, 1.3s (0.69s + 0.65s) on the branch.
  • MFA lockout: 16.6s on main, 4.4s on the branch.

Package totals, run back to back on the same machine:

Package -race -tags integration, main → branch -race -short, main → branch
internal/api 153.3s → 78.4s 113.3s → 43.0s
internal/auth 142.7s → 55.7s 96.9s → 17.6s
internal/purchase 91.6s → 55.1s 35.7s → 6.8s
internal/secrets 21.7s → 1.9s 23.7s → 3.3s
internal/server 119.4s → 24.0s 39.4s → 11.1s

Auth's unit time drops more than its two named tests account for. Every auth test that hits a failing Login was 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.

  • OpenAPI 403: with '403' renamed in one gated op of openapi.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 *Handler methods: same result for every method, 99 gated.
  • Purchase:
    • Save budget counted from the run start instead of after the run: require.NoError failed (save ctx expired). Main failed after 32s, the branch after 2.4s.
    • One per-rec budget shared across the whole run: "provider call must fit its own budget".
    • Production per-rec cap lowered to 15s: main's slow test failed. On the branch, the new lower-bound pin in ExpiredCallerDeadline failed.
  • Azure: the metadata-blocking transport swapped for one that refuses dials with a different error (so no real network access): both subtests failed on the Contains assertion.
  • OWASP: an inactive account given a distinct error message: the inactive_account subtest failed.
  • UpdateUserProfile: the current-password check skipped: the wrong_current_password subtest failed.
  • MFA lockout (integration):
    • Wrong codes not counted in rejectMFACode: 6 identical subtests failed on both.
    • Lock ignored in rejectLockedMFAUser: the 3 identical *-locks-after-max-failures subtests failed.
  • Rate limiter (integration):
    • Login limit set to 6: both modes failed.
    • Login rate check disabled: both modes failed.
  • Setup-admin round trip: the setup-admin password left base64-encoded (the bug from the round-trip test's issue): failed on both.
  • TestDummyHashForCompare_MatchesHashCost: with the default branch of dummyHashForCompare broken, it failed.

Suites and checks

  • go build ./..., go vet ./... and go vet -tags integration ./internal/...: clean.
  • go test -race -short -count=1 ./...: all 36 packages with tests pass.
  • go test -race -tags integration -count=1 on api, auth, purchase, secrets and server: pass.
  • A full go test -race -tags integration -json ./... pass, which is how I found the two adapter tests.
  • golangci-lint v2.10.1 on the five packages, with and without -tags integration: 0 issues.
  • gocyclo: no new functions over 10. The 4 hits in changed test files are pre-existing, identical on main, and outside the hook, which skips _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.
  • git-secrets scan of the changed files: clean, and a planted key was flagged. scripts/test-git-secrets-allowlist.sh: 137 pass.

Not verified locally

  • The CI runner's timings: this VM has 16 cores, the runner has 4 vCPUs.

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 -short time. 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.
    • TestMigrations_DownAllRoundTrip (about 26s) and the subtests of TestEnsureAdminUser_GroupAssignment, TestMigrations_AutoHealDirty, TestMigration_GrantViewConfigToNonAdminGroups and TestMigration_SplitSavingsPlans (about 4-7s each). Each subtest migrates a fresh database, which is the behavior under test. test(migrations): run integration tests in parallel; harden shared test server #1401 already runs them in parallel.
    • The one CPU item is migrate.go's cost-12 bcrypt for the bootstrap admin password, about 7s in "fresh insert with password" and about 4s in TestEnsureAdminUserWithPassword_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 changing RunMigrations' 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

  • The purchase test now overrides its budgets instead of waiting out the real 30s. The literal production durations stay pinned in TestExecuteAndFinalize_ExpiredCallerDeadline_UsesOwnBudgets, which now also pins the per-rec cap from below.
  • auth.ServiceConfig.BcryptCost is a new exported field, because the server tests build the auth service through NewService. Production leaves it at zero. If you'd rather not have an exported knob, the alternative is an exported test-only setter in internal/auth/test_helpers.go plus a hook in server. That is more moving parts for the same effect.
  • The branch merged origin/main (test(migrations): run integration tests in parallel; harden shared test server #1401) with a plain merge commit.

🤖 Generated with Claude Code

IAmJSD and others added 7 commits October 25, 2026 02:44
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>
@coderabbitai

coderabbitai Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Caution

Review failed

The pull request is closed.

Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

CI on head d807057 is green (pre-commit run 40160298385 and CI run 40160298330). Job wall times, one sample each:

Job main @ 8793aa3 (run 40158424768) this PR (run 40160298330)
Unit Tests 371s 256s
Integration Tests 509s 470s

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
IAmJSD marked this pull request as ready for review October 25, 2026 10:01
@IAmJSD
IAmJSD merged commit f5ad8c6 into LeanerCloud:main Oct 25, 2026
26 of 27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant