Skip to content

Assert what the mutation gate found unasserted in the two test-support packages - #324

Merged
Reefact merged 5 commits into
mainfrom
claude/automated-mutation-testing-4okfe0
Jul 27, 2026
Merged

Assert what the mutation gate found unasserted in the two test-support packages#324
Reefact merged 5 commits into
mainfrom
claude/automated-mutation-testing-4okfe0

Conversation

@Reefact

@Reefact Reefact commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Summary

The mutation gate scored FirstClassErrors.Testing at 45 % and JustDummies.Xunit at 57 %. This pull request asserts the behaviours those scores pointed at, marks the mutants that genuinely cannot be killed, and ratchets both thresholds up to the ground gained.

Package Before After break
FirstClassErrors.Testing 45.33 % 97.26 % 40 → 90
JustDummies.Xunit 57.14 % 85.71 % 50 → 80

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Refactoring
  • Analyzer / diagnostic change
  • Tests
  • Documentation
  • Build / CI / tooling

Changes

The low scores were not "the API is untested" — the happy paths were already covered from FirstClassErrors.UnitTests. What survived was four families of behaviour that tests executed without asserting:

  • Argument guards. Every public entry point documents an ArgumentNullException that nothing proved; each of those throws could be deleted with the suite staying green.
  • Failure-message contents. This package's product is what it prints when an expectation fails, and every one of those strings could have been replaced by an empty one unnoticed. Each message is now required to name both the expected and the actual value, an absent context entry to list the keys that are present, and a null to render as null rather than as an empty string.
  • Arbitrary-value shapes. Any detailed message 7F3A9C. announces itself as arbitrary where a bare random string would read as a value someone chose. Nothing asserted the prefix, the trailing period, or that the factories vary at all.
  • The adapter's hooks. A decorated test never watches its own After run, so nothing established that the scope was closed rather than abandoned, nor what happens when After runs without a matching Before.

The survivors that remain, and why

Four, none of them a missing test — each verified by reading the code it depends on rather than assumed:

  • OutcomeAssertions.Describe, null branch (2). Unreachable: Outcome.Success rejects null, so a successful outcome cannot carry one. Left uncounted rather than marked, because a marker would have silenced the two killed mutants sharing that line.
  • ReproducibleAttribute, writing the report and reading the outcome (2). Observable only from a test that has already failed. This is the limit the design anticipated when it extracted ReportFor: the rule is tested, the wiring is not. Closing it would mean injecting an output sink into the attribute — a design change, not a test, and not one to make unasked.

Two further guards were equivalent and are now marked in place with // Stryker disable once Statement,Block and their reason: WithContextEntry's guard duplicates one the dictionary lookup performs with the same exception and parameter name, and InstanceIds.Use duplicates the guard inside AmbientInstanceId.Use. Both mutators are named because Stryker generates both on such a guard; Equality is deliberately left out, since a test kills it and it must stay counted.

Clock.Use looks like the same case and is not — it forwards a lambda and never the argument, so its guard is the only one there is. It carries no marker, and a test now proves it.

Testing

  • dotnet build FirstClassErrors.sln
  • dotnet test FirstClassErrors.sln
  • Analyzer tests pass (FirstClassErrors.Analyzers.UnitTests)

Whole solution green: 1 844 tests, 0 failures, across the 13 test projects.

Both thresholds were verified without --break-at 0, so the process exit code proves the bar actually holds: testing exits 0 at 97.26 % against 90, justdummies-xunit exits 0 at 85.71 % against 80. Every score quoted here comes from a serialised run with no Pending mutants — two earlier runs were disturbed by concurrent Stryker processes and their numbers discarded.

Re-measured after merging main

main moved to 6759b94 while this branch was open, and #323 rewrote the ambient seed scopes in RandomSource into a linked stack tolerating out-of-order disposal — the exact machinery ReproducibleAttributeHookTests asserts through NestedScopesUnwindInOrder and TheAfterHookClosesTheScope. The merge is textual-conflict-free and touches none of this branch's files, but that is not on its own an argument that the behaviour still holds, so both were re-run against the merged tree: the whole solution stays green, and both scores come back identicaltesting 71 killed / 2 survived (97.26 %), justdummies-xunit 12 / 2 (85.71 %), no Pending mutants, both exiting 0 against the raised thresholds.

Documentation

  • Public API / error documentation updated
  • README / doc/ updated
  • French translation (doc/handwritten/for-users/README.fr.md) updated if user-facing behavior changed
  • No documentation change required

No user-facing behaviour changed. The two source edits are comments; the reasoning they carry lives next to the code it explains, which is where the next sweep will need it.

Architecture decisions

  • No architectural decision in this pull request
  • New decision recorded — ADR drafted as Proposed: ADR-____
  • Supersedes an existing ADR — successor proposed, status not flipped: ADR-____
  • ⚠️ Conflicts with an existing ADR — flagged for the maintainer: ADR-____

This applies ADR-0043's ratchet rather than deciding anything new.

Related issues

None.


Generated by Claude Code

claude added 5 commits July 27, 2026 17:08
The mutation gate scored this package at 45 %: its happy paths were covered
from FirstClassErrors.UnitTests, but four families of behaviour were executed
without being asserted, and could be deleted with the suite staying green.

Argument guards. Every public entry point documents an ArgumentNullException
it never proved: removing any of those throws changed nothing. Note that
Clock.Use and InstanceIds.Use are not the same case — Clock forwards a lambda
and never the argument, so its guard is the only one there is.

Failure messages. This package's product is what it prints when an
expectation fails, and every one of those strings could be replaced by an
empty one unnoticed. Each message is now required to name both the expected
and the actual value, an absent context entry to list the keys that are
present, and a null to render as null rather than as an empty string.

Arbitrary-value shapes. `Any detailed message 7F3A9C.` announces itself as
arbitrary where a bare random string would read as a value someone chose.
Nothing asserted the prefix, the trailing period, or that the factories vary
at all — a factory pinned to a constant would have passed.

The context-key separator check reads the key list alone rather than the whole
message: the surrounding prose carries commas of its own, and a check over the
full string passes whether or not the separator survived.
Two argument guards cannot be killed by any test, and the reason is worth
stating next to the code rather than rediscovering at the next sweep.

WithContextEntry's guard duplicates one the dictionary lookup below already
performs, with the same exception and the same parameter name. InstanceIds.Use
duplicates the guard inside AmbientInstanceId.Use, identically. Removing either
is unobservable; both stay because they state the contract where the caller
reads it. Clock.Use is deliberately not marked: it forwards a lambda and never
the argument, so its guard is the only one there is — and a test now proves it.

Each carries a `// Stryker disable once Statement,Block` with its reason. Both
mutators are named because Stryker generates both on such a guard and marking
only one leaves the other surviving for the very reason the comment gives; the
`Equality` mutator is deliberately left out, since a test kills it and it must
stay counted. A lowered threshold would have hidden every future survivor along
with these two.
The suite observes the attribute from inside a decorated test, which is the
right way to prove that pinning works but leaves the hooks' own behaviour
unseen: a test never watches its own After run, so nothing established that
the scope was closed rather than abandoned, nor what happens when After runs
without a matching Before.

Four behaviours are now asserted: a declared seed is read back, an undeclared
one reads as zero, After without a scope is a no-op rather than a
NullReferenceException, and After releases the ambient source instead of
leaking the seed into whatever runs next. A fifth covers the nesting the
method, class and assembly levels rely on — closing the inner scope must
restore the outer seed, not the unpinned source.

Both hooks ignore their two parameters, so they are passed as null: naming a
real MethodInfo and a fake IXunitTest would suggest they matter.

Two survivors are deliberately left. Writing the report to the test output and
reading the finished test's outcome are only observable from a test that has
already failed, which is exactly why ReportFor was extracted — the rule is
tested, the wiring is not.
Both bars were calibrated on scores that no longer hold. `FirstClassErrors.Testing`
was set at 40 when it measured 45 %; it now measures 97 %. `JustDummies.Xunit`
was set at 50 when it measured 57 %; it now measures 86 %. Left where they were,
the bars would have permitted the regression of everything just asserted without
a single check turning red — which is the one thing a ratchet exists to prevent.

They move to 90 and 80: below the measured scores, with room for one equivalent
mutant to appear under a later refactoring, and far enough above the previous
values to hold the ground that was gained.

Four survivors remain and none is a missing test. Two are the unreachable null
branch of OutcomeAssertions.Describe — Outcome<T>.Success rejects null, so a
successful outcome cannot carry one — left uncounted rather than marked, because
a marker would have silenced the two killed mutants sharing that line. The other
two are the Reproducible attribute writing its report and reading the finished
test's outcome, both observable only from a test that has already failed.
@Reefact
Reefact merged commit 2ed5a97 into main Jul 27, 2026
29 checks passed
@Reefact
Reefact deleted the claude/automated-mutation-testing-4okfe0 branch July 27, 2026 19:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants