From a159fbbf6212c1490c29fc91cc367efb1a9d4e9d Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Sat, 29 Aug 2026 16:12:59 -0700 Subject: [PATCH] perf(mutation): stop booting a Postgres container for every mutant Stryker was spending most of its runtime starting databases, not testing mutants. A 9-file diff kept it busy for 30+ minutes on the CI runner, with 18 Postgres containers alive at once and the box swapping. Cause: Stryker runs vitest once per mutant batch across 'concurrency' workers (default: CPU cores - 1, so 9 on the Mac mini). vitest.config.ts declares globalSetup, which boots a testcontainers Postgres AND replays the whole migration chain -- and that fires on every worker. vitest.related already limits which tests run, but globalSetup runs regardless of which tests are selected, so Postgres booted even to run money.test.ts. Measured, one test file with 29ms of tests: 6.66s -> 1.37s. The full 53-file DB-free unit suite now runs in 6.47s with zero containers. Stryker on money.ts: dry run 24s/192 tests -> 3s/111 tests, mutation phase 2m42s -> 1m52s, peak containers 7 -> 0. The gap is far wider on the 16GB mini. Fix: a Stryker-only vitest config via the supported vitest.configFile option, with no globalSetup, excluding tests/ and the five colocated src tests that need a live database. Adds ignoreStatic, which skips mutants that only run at file load -- the ones that force an environment reload. vitest.config.ts is untouched, so pnpm test and CI's test job are unaffected (113 files / 751 tests still pass). Mutation score on money.ts moves 77.32% -> 75.26%, and the composition is better than the number suggests: before: 71 killed, 4 timeout, 22 survived after: 73 killed, 0 timeout, 24 survived It kills more mutants outright. The old run's 4 timeouts -- which Stryker counts as kills -- were mutants timing out on container contention, not tests catching them. Part of the old score was infrastructure noise scored as success. Mutants covered only by DB-backed tests now report NoCoverage. That is honest signal about unit-test strength, and it makes ci.yml's claim that Stryker 'runs just the unit suite' true -- it was not before. Note: coverageAnalysis is kept for documentation only. The vitest runner ignores it and always uses perTest, but ignoreStatic documents perTest as a prerequisite. --- stryker.config.json | 16 +++++++++++++--- vitest.stryker.config.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 vitest.stryker.config.ts diff --git a/stryker.config.json b/stryker.config.json index 4462ae31..dfb3df50 100644 --- a/stryker.config.json +++ b/stryker.config.json @@ -13,7 +13,9 @@ "@stryker-mutator/typescript-checker" ], "testRunner": "vitest", - "checkers": ["typescript"], + "checkers": [ + "typescript" + ], "tsconfigFile": "tsconfig.json", "coverageAnalysis": "perTest", "thresholds": { @@ -21,8 +23,16 @@ "low": 60, "break": 60 }, - "reporters": ["html", "clear-text", "progress"], + "reporters": [ + "html", + "clear-text", + "progress" + ], "htmlReporter": { "fileName": "reports/mutation/mutation.html" - } + }, + "vitest": { + "configFile": "vitest.stryker.config.ts" + }, + "ignoreStatic": true } diff --git a/vitest.stryker.config.ts b/vitest.stryker.config.ts new file mode 100644 index 00000000..e8c2a82d --- /dev/null +++ b/vitest.stryker.config.ts @@ -0,0 +1,37 @@ +import { defineConfig } from "vitest/config"; + +// Vitest config used ONLY by Stryker (wired via stryker.config.json's +// `vitest.configFile`). It deliberately omits the `globalSetup` that starts a +// Postgres testcontainer. +// +// Why: Stryker runs vitest once per mutant batch across `concurrency` workers +// (default: CPU cores - 1). globalSetup fires on every one of those, so a +// Postgres container was being booted and fully migrated to run unit tests that +// take milliseconds — measured at 8.92s of setup for 29ms of tests, and 18 +// concurrent containers observed on the runner. +// +// Consequence: tests that need a database can't run here, so the DB-backed +// files below are excluded along with tests/ (integration). Mutants covered +// only by those report as NoCoverage rather than Survived, which is honest +// signal about unit-test strength instead of a container tax. +export default defineConfig({ + resolve: { + tsconfigPaths: true, + }, + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts"], + exclude: [ + "node_modules/**", + "e2e/**", + // Need a live Postgres via tests/global-setup.ts. + "src/lib/demo-mode.test.ts", + "src/lib/simplefin/queries.test.ts", + "src/lib/plaid/queries.test.ts", + "src/lib/jobs/backfill-balances.test.ts", + "src/lib/jobs/backfill-transfers.test.ts", + ], + testTimeout: 30_000, + }, +});