How to speed up a slow Stryker run
A mutation run on a small diff should take seconds of test time. In practice it often takes most of a minute, and almost none of that is tests. This guide shows where the time went in one measured case and which Stryker settings changed it.
The measured case
The numbers come from Reapr’s bench report: one package of a pnpm + Turborepo monorepo (~55k files incl. build caches), StrykerJS 9.6.1, Vitest 4, a diff that produced 3 mutants, and medians of 5 runs per variant on a 22-thread Linux machine. Every run was under load from other jobs, so treat absolute seconds as inflated. The comparisons between variants are what matter.
Where the time goes
A median run took 54.0 s. About 85% of it happened before the first test ran:
| Phase | Median | Share |
|---|---|---|
| Start, load the config, walk 55,389 files | 18.0 s | 33% |
| Instrument and preprocess | 4.1 s | 8% |
| Checker, sandbox copy, test runner startup | 24.8 s | 46% |
| Initial test run (83 tests) | 1.4 s | 3% |
| Testing the 3 mutants | 2.7 s | 5% |
| Reporters and temp-dir cleanup | 1.7 s | 3% |
Testing, the initial run plus the mutants, was about 8% of the wall time. Before you tune test speed, look at startup.
Keep build caches out of the sandbox
Stryker copies your project into a sandbox directory before it runs anything. This finding applies to
Turborepo repos with a local cache inside the repository (.turbo/cache); not every monorepo has one, and a
review of 8 open source Stryker repositories found none. In the measured package, .turbo/cache was 53,832 of
the 55,390 files copied (97%) and 1,277.6 MB of the 1.288 GB (99.2%). The config’s ignorePatterns did not
exclude it.
Adding .turbo to ignorePatterns took a plain Stryker run from 38.4 s to 12.5 s (−67%), with identical
results. CPU time fell from 62.8 s to 19.8 s and peak memory from 607 MB to 399 MB.
// stryker.config.mjs
export default {
// ...your existing config
ignorePatterns: [".turbo"],
};
Stryker 9.6.1 and 10.x always ignore node_modules, .git, *.tsbuildinfo, /stryker.log, .next, .nuxt
and .svelte-kit. Anything else in the project directory is copied. Other large directories that your tests never read are candidates too, but do not
ignore files your test runner needs, such as its config or fixtures.
Stryker prints how many files it found at the default log level, in a line like
ProjectReader Found N of M file(s). If M is far above the number of source files you expect, list the largest
directories:
du -sh .[!.]* * 2>/dev/null | sort -h | tail
Point the TypeScript checker at the files you mutate
The TypeScript checker builds a TypeScript program to discard mutants that do not compile. In the measured
package it cost about 3.2 s and checked nothing. The tsconfig it used had no include, so it covered only
three shared Vitest config files. The mutated package was outside that program, and the checker let every
mutant through.
Check what your checker’s tsconfig covers:
npx tsc --listFilesOnly -p tsconfig.json | grep -v node_modules
If the files you mutate are not in the list, point tsconfigFile at a tsconfig that includes them, or remove
the checker if you do not need compile-error filtering:
export default {
checkers: ["typescript"],
tsconfigFile: "tsconfig.json", // must include the files you mutate
};
Use debug logging only to diagnose
--logLevel debug is useful to find which phase is slow, but it is not free. In the measured case it added
21 s to the median wall time (59.4 s against 38.4 s at the default level). In one run it held Stryker’s event
loop for about 4 s while it printed the list of 55k files.
Do not raise concurrency for small diffs
With 3 mutants, --concurrency 8 made the run slower, not faster. Starting four TypeScript checkers in
parallel took 8.9 s against 2.9 s, and mutant testing saved only 1.1 s. The phases added up to 5.7 s more. A
bigger pool pays off when there are many mutants to spread across it.
About coverageAnalysis
coverageAnalysis decides which tests run against each mutant. With perTest, the default since Stryker 5,
Stryker runs only the tests that cover the mutant. With off, it runs every test for every mutant. Stryker’s
docs say it does not change the score. The Vitest runner ignores the setting and always uses perTest, so for
Vitest projects there is nothing to tune here. For Jest, Mocha or Jasmine, keep perTest unless your tests
depend on each other’s order.
What Reapr does here
Reapr runs your Stryker config as written and does not change any of these settings. In the measured runs,
Reapr’s own work outside the Stryker process took 1.7 s. It records the engine time, CPU time and peak memory
of every run in .git/reapr/runs/<run_id>/run.json, so you can compare a config change against the runs before
it.