bdfinst

stryker-xunit-v2-shim

Build a xunit.v2 Stryker shim so Stryker.NET produces a valid mutation score for a xunit.v3 test project. Stryker.NET cannot observe mutant kills through xunit.v3 (it runs on the Microsoft Testing Platform), so a normal run reports a false near-zero score with almost every mutant reported Survived. Use this BEFORE running Stryker whenever the target .NET test project references xunit.v3 — including when mutation is enabled via /test-improve or /mutation-testing, or you are about to run dotnet-stryker — and as a rescue when a run already reported ~0% or everything Survived or the user says the score looks suspiciously low. When Stryker and xunit.v3 both appear, build the shim first.

bdfinst 298 35 Updated 4w ago

Resources

2
GitHub

Install

npx skillscat add bdfinst/agentic-dev-team/stryker-xunit-v2-shim

Install via the SkillsCat registry.

SKILL.md

Stryker xunit.v2 Shim

Role: worker. Builds measurement scaffolding and does not modify the tests it
measures.

Why this exists

Stryker.NET (through at least 4.15/4.16) cannot observe mutant kills through
xunit.v3
. xunit.v3 runs on the Microsoft Testing Platform (MTP), and Stryker's
per-test coverage/kill mapping doesn't work across it (stryker-net issues 3237,
3629, 3094). The run completes and the initial test pass even succeeds, so the
failure is silent — the only symptom is the score: near-zero, with almost
every mutant reported Survived (e.g. 0.13% — 3 killed / 7,685 survived). That
is the observation-failure signature, not a real result.

The fix is a shim: a second test project that recompiles the same test source
files
under xunit.v2 (classic VSTest), which Stryker observes correctly. No test
is rewritten — the shim links the existing .cs files, so there is one source
of truth. Stryker is then run from the shim directory in project mode.

`references/shim-howto.md` has the full reference build
and any edge case not covered here.

Version scope — this whole skill is a < 5.0.0 workaround

Everything below applies to Stryker.NET < 5.0.0. Starting at 5.0.0
(stryker-net#3752),
-t mtp + coverage-analysis: perTest observes mutant kills through xunit.v3
directly — no shim, no v3-only-construct porting, no operator gate. See
`csharp-stryker-net.md`
for the >= 5.0.0 command and its concurrency: 1 caveat
(stryker-net#3832,
tracked for removal in #2192).
stryker_xunit_shim_guard.py detects the installed version
(hooks/mutation_adapters/stryker_net.py::stryker_net_version()) and
silent-passes on >= 5.0.0 — this skill's Step 1a operator gate is never
reached on a known >= 5.0.0 install. An undetermined version fails closed to
the < 5.0.0 behavior this skill documents.

Scope — one path, not the only one

The shim is the path that keeps the mutant-kill loop viable on xunit.v3 below 5.0.0, because it restores per-test coverage (fast covering-subset per mutant). It is not mandatory for every xunit.v3 run. Within mutation-kill the shim-first feasibility gate (#1158) decides per run: build the shim and probe under perTest; if per-test capture works and a round fits the budget, enter the loop; otherwise degrade to the no-shim floor — the real v3 suite via -t mtp + coverage-analysis: off (a real but slow, whole-suite-per-mutant single advisory pass; see `csharp-stryker-net.md`). Before building, `xunit_v3_feature_detector.py` classifies the shim-breaking v3-only constructs for the always-ask operator gate in Step 1a — enforced by the guard hook, which stays blocked until the operator's choice is recorded (#1791). On exclude, the guard passes their selections to generate_shim.py --compile-exclude itself.

When to build it

Detection is one check: a test .csproj contains Include="xunit.v3". Build the
shim proactively, before the first Stryker run — because the failure is silent
(the run succeeds and the score is bogus), waiting for the symptom wastes a full,
possibly hours-long run. As a backstop the stryker_xunit_shim_guard.py PreToolUse
hook auto-scaffolds the shim (and reports what it wrote) when you run
dotnet-stryker against a xunit.v3 project whose sources are clean, then blocks so
you re-run from the shim dir; when the sources are not clean it presents the
Step 1a operator gate instead of scaffolding. Build proactively rather than relying
on the interception. (The guard exempts an explicit -t mtp run — the no-shim floor — so it is not forced into a shim; see the Scope section.) Within mutation-kill the feasibility gate may still degrade to that floor after the shim is built and probed.

One precondition decides whether the shim is cheap: source compatibility with
xunit.v2. [Fact], [Theory], [InlineData], TheoryData<>, MemberData,
ClassData, and Assert.* are source-identical between v2 and v3, so a plain
suite ports in minutes. AutoFixture.Xunit3 [AutoData]/[InlineAutoData] are
not v2-compatible. The Step 1 scope probe quantifies how much of that a suite
has; when there is any, Step 1a's operator gate decides what to do about it —
"say so and stop" is not yours to choose, it is one of four options the operator
picks between.

Canonical invocations — copy these, don't improvise

Every command below is written to run from the repository root unless it says
otherwise. Guessing at the working directory or at a flag that doesn't exist is
the single biggest source of retried commands here (#1777), so paste these and
substitute only <TestProject>.

# Detect (repo root) — a test .csproj referencing xunit.v3 is the whole check
git grep -l 'Include="xunit.v3"' -- '*.csproj'

# Scope probe (repo root) — every hit is a file the operator gate will ask about
git grep -n 'AutoFixture.Xunit3\|\[AutoData\|InlineAutoData\|AutoMoqData\|MemberAutoData' -- '*.cs'
git grep -n 'TestContext\|Assert.Skip\|Assert.Multiple\|Assert.Equivalent\|ValueTask InitializeAsync' -- '*.cs'

# Green baseline (repo root) — expect the SAME test count as the real project, 0 failed
dotnet build tests/<TestProject>.Mutation -c Release
dotnet test  tests/<TestProject>.Mutation -c Release --no-build

# Mutation run — the ONLY correct form: from the shim dir, no arguments
cd tests/<TestProject>.Mutation
dotnet-stryker

# No-shim floor (from the REAL test project dir) — set "coverage-analysis": "off"
# in stryker-config.json first; it is config-file-only, there is no CLI flag
cd tests/<TestProject>
dotnet-stryker -t mtp

Three invocations that look right, fail, and get retried:

Don't Do instead Why
dotnet-stryker at the repo root cd tests/<TestProject>.Mutation && dotnet-stryker A root run auto-detects the .sln, enters solution mode, and binds to the real xunit.v3 project → false ~0%
dotnet-stryker --solution ../../App.sln (or a SolutionPath in the shim's config) omit it entirely — test-projects in the shim's stryker-config.json is the selection With both set Stryker enumerates the solution and prefers the v3 project over the shim (the SolutionPath trap, #557)
dotnet-stryker --coverage-analysis off set "coverage-analysis" in stryker-config.json coverage-analysis is config-file-only in Stryker.NET 4.15 — there is no CLI flag, so the run dies on an unknown option

Shim selection is not a command-line argument at all: it is the cwd having no
.sln (project mode) plus the test-projects entry the generator wrote. If a
run reports the observation-failure signature from a shim directory, check those
two before reaching for a flag.

Workflow

Read before editing

Before editing an artifact this workflow has already produced, read it first — each of these only exists once the workflow has reached the step that creates it, so don't expect all three on the proactive, before-the-first-run path:

  • the mutant-survivors report (StrykerOutput/<timestamp>/reports/mutation-report.json) — once a Stryker run has completed
  • the shim config (stryker-config.json) — once Step 2 has created it
  • the step files (the shim's linked test source files) — once they exist on disk

Converged-state check. Compare each file's current content against the
target state described in the step you're about to run before editing it.
If a file already matches its target state, skip re-editing it — do not
overwrite a file that has already converged.

Step 1 — Scope probe (decide whether the shim is cheap)

Run against the branch under test to find v3-only usage that would need porting:

# v3-incompatible attribute usage in test sources
git grep -l "AutoFixture.Xunit3\|\[AutoData\|InlineAutoData\|AutoMoqData\|MemberAutoData" -- 'tests/**/*.cs'
# other v3-only APIs
git grep -l "TestContext\|Assert.Skip\|Assert.Multiple\|IAsyncLifetime\|Assert.Equivalent" -- 'tests/**/*.cs'

Each hit is a file the operator gate below will ask about. Zero hits → the shim
builds unattended, proceed to Step 2.
Any hits → Step 1a; do not decide for the
operator, and in particular do not quietly conclude the approach "isn't a good
fit" — that is one of four options, and it is theirs to pick.

Step 1a — Operator gate (always-ask; you cannot skip it)

When the probe finds shim-breaking constructs, the operator chooses how to
proceed. This is enforced, not advisory: stryker_xunit_shim_guard.py blocks
every dotnet-stryker invocation against that project until a choice is
recorded, so paraphrasing the gate or picking on the operator's behalf does
not get the run through (#1791).

The block body is the question, already assembled: the per-construct breakdown
from `xunit_v3_feature_detector.py` (which construct, translatable or
not, coverage-bearing or neutral, with file and line), the four options with
their tradeoffs, and the exact record command carrying this blocker set's
fingerprint. Present the four options to the operator with AskUserQuestion,
then run the printed command verbatim — the fingerprint is per-blocker-set, so
don't reuse one from an earlier run.

Choice What happens next
port Rewrite the flagged constructs to v2-compatible forms (Step 3). The guard auto-scaffolds once the sources are clean.
exclude The guard scaffolds with --compile-exclude on the flagged files. They stay unmeasured — their mutants report as survivors.
skip Deactivate just the offending tests, then re-run. Clears the gate only where the deactivation removes the construct itself ([Fact(Explicit = true)] → [Fact(Skip = "…")]); a construct in a test body or on a data attribute stays in the source the shim compiles, so those need port or exclude. Undo at teardown.
degrade Skip the shim; run the no-shim floor (-t mtp, coverage-analysis: off) for one slow advisory pass. Only relevant below 5.0.0 — this whole gate is bypassed on a known >= 5.0.0 install (see Version scope above).

The fingerprint is mandatory — it is what scopes a decision to the blockers
the operator actually saw. Add a new blocking file after they answered and the
gate re-asks. A reply matching none of the four is re-asked with the same
four; never default or guess.

Step 2 — Scaffold the shim project

Use the bundled script — it reads the real test .csproj and writes the shim
.csproj (mirroring every non-xunit PackageReference verbatim, swapping in the
xunit.v2 stack, setting RootNamespace/AssemblyName, adding the linked-Compile
glob and the product ProjectReference) plus a stryker-config.json:

python3 "${CLAUDE_PLUGIN_ROOT}/skills/stryker-xunit-v2-shim/scripts/generate_shim.py" tests/<TestProject>/<TestProject>.csproj \
  --mutate-exclude '**/gRPC/**/*.cs' --mutate-exclude '**/Caching/**/*.cs'

It creates tests/<TestProject>.Mutation/ beside the real project. Pass
--mutate-exclude for each product folder that's genuinely out of mutation scope
(generated code, gRPC stubs, etc.); omit if none.

Why these choices matter, so you can fix the file by hand if the script's
heuristics miss something in an unusual .csproj:

  • RootNamespace must equal the real project's root namespace — the linked
    sources resolve the same types, so a mismatch breaks compilation.
  • AssemblyName must differ (.Mutation) so both projects coexist.
  • Non-xunit package versions must match exactly — a version drift causes
    compile errors in the linked sources.
  • Keep the shim out of the .sln (see Step 4 — this is what forces project
    mode). Do not dotnet sln add it.

Step 3 — Port v3-only constructs (only files Step 1 flagged)

Rewrite v3-only APIs to behavior-equivalent v2-compatible forms, in the real
test project's sources
(the shim links those same files, so one edit satisfies
both). Verify each replacement still compiles under v3 too.

v3-only v2-compatible replacement
TestContext.Current.CancellationToken CancellationToken.None (token was never cancelled for a passing test)
[AutoData] / [InlineAutoData] manual new Fixture() in the body, or [Theory] + [InlineData] — heavier; avoid the shim if pervasive
Assert.Skip(...) [Fact(Skip="…")] or a guard-and-return

Step 3a — Grant internals access

If the product exposes internals to the real test assembly via
InternalsVisibleTo, add the shim assembly too (its tests exercise internal
types):

[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("<TestProject>.Mutation")]

Step 4 — Run Stryker FROM the shim directory (the critical part)

cd tests/<TestProject>.Mutation
dotnet-stryker

This directory choice is the whole game. A bare dotnet-stryker at repo root
auto-detects the single .sln, enters solution mode, ignores test-project
selection, and binds to the real xunit.v3 project → false ~0%. Running from a
directory with no .sln forces project mode: Stryker uses the shim in the
cwd and finds the product through its ProjectReference. The stryker-config.json
the script wrote (with coverage-analysis: perTest and additional-timeout: 30000) lives beside the shim; perTest mode is required for kill observation and
the raised timeout keeps slow mutants from being scored as timeouts.

Step 5 — Validate BEFORE the full run

A full run can take hours; a wrong harness wastes all of it. Two gates:

  1. Green baseline — the shim builds and every test passes:
    dotnet build tests/<TestProject>.Mutation -c Release
    dotnet test  tests/<TestProject>.Mutation -c Release --no-build
    # expect the SAME test count as the real project, 0 failed
  2. Kills register — temporarily scope mutate to one small, well-covered file
    and run Stryker from the shim dir. Expect a non-zero score with real kills.
    If this scoped run is still ~0%, the harness is wrong — do not launch the full
    run
    ; recheck the directory (Step 4) and the shim references.

Step 6 — Launch the full run, then sanity-check

Widen mutate back to the full scope and launch. A full Stryker run is
long-running — start it in the background and watch it (a Monitor plus a periodic
status check), rather than blocking on it. If the periodic check is a scheduled
wake-up, follow the calling contract in
`knowledge/long-run-waiting.md` — a
wake-up armed without its replay instruction schedules nothing.

Before recording any score, reject the observation-failure signature: a
near-zero score with almost everything Survived means it bound to the v3 project
again — rerun from the shim dir. A healthy run kills the majority of covered
mutants. Output lands at
StrykerOutput/<timestamp>/reports/mutation-report.{html,json}.

Teardown

The shim, its stryker-config.json, the InternalsVisibleTo line, and any v3→v2
source ports are measurement scaffolding. If you were measuring a throwaway
branch/worktree, discard it. If the shim lives in the repo, keep it out of the
.sln and out of CI test discovery
so it never runs as a normal suite and never
double-counts.