The Changed code mutation gate returns a different score for the same tree. On PR #164 I ran it four times:
| run |
score |
survived |
| CI |
66.67% |
3 |
| local |
55.56% |
4 |
| local, after adding 6 tests |
77.78% |
2 |
| local, identical tree, immediately after |
88.89% |
1 |
The last two runs are the same commit on the same machine, back to back. The threshold is 80%.
The mutants reported as survived are demonstrably killed
Two mutants in src/Alberto.Messaging/MessagingBuilderExtensions.cs were reported Survived in run 3. I hand-applied each mutation and ran the fast suite:
- L79,
configureMappings(registry); → ; — 2 tests fail.
- L86, the
AddSingleton<IHostedService>(… OutboxRetentionService …) statement → ; — WithOutbox_RegistersRetentionServiceEvenWithoutTransport fails at OutboxHandlerTests.cs:212.
So the suite kills both. Stryker said otherwise, and then said otherwise again differently on the next run.
Suspected cause
Both flaky mutants report coveredBy = 2069 tests, i.e. the entire suite — Stryker's fallback when it cannot attribute coverage to a block. Both live inside the deferred builder.Register(context => { … }) lambda, which does not execute at the point WithOutbox is called; it runs later, when a test drives DeferredRegistrations.
stryker-config.json sets "test-runner": "mtp". Every run prints:
The Microsoft Test Platform testrunner is currently in preview. Results should be verified
since this feature is still being tested.
tests/Alberto.Tests has no xunit.runner.json, so xUnit v3 parallelism is at its default. A per-test coverage collector that races parallel execution would produce exactly this: unattributable coverage collapsing to "all tests", and an unstable per-mutant verdict on the deferred-lambda mutants.
Also visible every run, and possibly related: 5 mutants got status CompileError in this same file, and 3 mutants got status Ignored. Reason: Removed by block already covered filter — and which 3 are filtered moves between runs, so the scored set itself is not stable.
Why this matters more than the percentage
Small packages amplify it. Alberto.Messaging scores 9 mutants, so one flaky verdict is 11 points and straddles the 80% line. A PR can fail the gate for reasons that have nothing to do with its diff, and — worse in the other direction — a genuinely uncovered mutant can be reported killed and pass. A gate whose verdict is not reproducible cannot distinguish "this diff is well tested" from "we got a lucky roll", which is the same false-all-clear shape as #155.
Suggested directions
Not proposing a fix here, just what seems worth trying:
- Pin test parallelism during mutation runs (an
xunit.runner.json with parallelizeTestCollections: false, or maxParallelThreads: 1) and re-run the same tree several times to see whether the variance disappears. Costs wall-clock; the diff gate is already the throughput constraint, so measure before adopting.
- Try
"test-runner": "vstest" instead of the preview mtp runner and compare stability.
- Investigate the 5
CompileError mutants in MessagingBuilderExtensions.cs — Stryker failing to compile its own mutants of that file suggests its instrumentation of it is shaky, which may be the same root cause.
- Whatever the fix, add a repeatability check: run the gate twice on an unchanged tree in the nightly sweep and fail loudly if the two scores disagree. Right now nothing would have caught this.
Do not "fix" this by lowering the threshold or excluding the file. The tests are real and the mutants are genuinely killed; the reporting is what is broken.
The
Changed codemutation gate returns a different score for the same tree. On PR #164 I ran it four times:The last two runs are the same commit on the same machine, back to back. The threshold is 80%.
The mutants reported as survived are demonstrably killed
Two mutants in
src/Alberto.Messaging/MessagingBuilderExtensions.cswere reportedSurvivedin run 3. I hand-applied each mutation and ran the fast suite:configureMappings(registry);→;— 2 tests fail.AddSingleton<IHostedService>(… OutboxRetentionService …)statement →;—WithOutbox_RegistersRetentionServiceEvenWithoutTransportfails atOutboxHandlerTests.cs:212.So the suite kills both. Stryker said otherwise, and then said otherwise again differently on the next run.
Suspected cause
Both flaky mutants report
coveredBy= 2069 tests, i.e. the entire suite — Stryker's fallback when it cannot attribute coverage to a block. Both live inside the deferredbuilder.Register(context => { … })lambda, which does not execute at the pointWithOutboxis called; it runs later, when a test drivesDeferredRegistrations.stryker-config.jsonsets"test-runner": "mtp". Every run prints:tests/Alberto.Testshas noxunit.runner.json, so xUnit v3 parallelism is at its default. A per-test coverage collector that races parallel execution would produce exactly this: unattributable coverage collapsing to "all tests", and an unstable per-mutant verdict on the deferred-lambda mutants.Also visible every run, and possibly related:
5 mutants got status CompileErrorin this same file, and3 mutants got status Ignored. Reason: Removed by block already covered filter— and which 3 are filtered moves between runs, so the scored set itself is not stable.Why this matters more than the percentage
Small packages amplify it.
Alberto.Messagingscores 9 mutants, so one flaky verdict is 11 points and straddles the 80% line. A PR can fail the gate for reasons that have nothing to do with its diff, and — worse in the other direction — a genuinely uncovered mutant can be reported killed and pass. A gate whose verdict is not reproducible cannot distinguish "this diff is well tested" from "we got a lucky roll", which is the same false-all-clear shape as #155.Suggested directions
Not proposing a fix here, just what seems worth trying:
xunit.runner.jsonwithparallelizeTestCollections: false, ormaxParallelThreads: 1) and re-run the same tree several times to see whether the variance disappears. Costs wall-clock; the diff gate is already the throughput constraint, so measure before adopting."test-runner": "vstest"instead of the previewmtprunner and compare stability.CompileErrormutants inMessagingBuilderExtensions.cs— Stryker failing to compile its own mutants of that file suggests its instrumentation of it is shaky, which may be the same root cause.Do not "fix" this by lowering the threshold or excluding the file. The tests are real and the mutants are genuinely killed; the reporting is what is broken.