Skip to content

The mutation gate returns a different score for the same tree #167

Description

@VDBBjorn

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:

  1. 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.
  2. Try "test-runner": "vstest" instead of the preview mtp runner and compare stability.
  3. 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.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions