Skip to content

test: raise mutation score to ~97% — real tests over disables (#205) - #317

Merged
Chris-Wolfgang merged 5 commits into
vNextfrom
test/205-raise-mutation-score
Jul 23, 2026
Merged

test: raise mutation score to ~97% — real tests over disables (#205)#317
Chris-Wolfgang merged 5 commits into
vNextfrom
test/205-raise-mutation-score

Conversation

@Chris-Wolfgang

@Chris-Wolfgang Chris-Wolfgang commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Summary

Follow-up to #316. Raises the Stryker mutation score 86.61% → ~97% — and, after review feedback, converts the mutants that were merely disabled into ones that are genuinely tested, leaving only provably-equivalent mutants annotated.

Refs #205.

Mutants turned into real tests (were // Stryker disable, now killed)

  • ConfigureAwait(false)ConfigureAwaitContextTests installs a counting SynchronizationContext and asserts the library never posts a continuation back (the deadlock-safety property for WPF/WinForms/legacy-ASP.NET consumers). A ConfigureAwait(true) mutation posts and fails. All ConfigureAwait mutants killed.
  • GC.SuppressFinalizeFinalizationSuppressionTests derives a finalizer-bearing double, disposes it, forces a GC, and asserts the finalizer did not run. Dropping SuppressFinalize lets it run and fails. (The shipped base types have no finalizer, so the normal cross-TFM run always passes deterministically; the mutant is only exercised under Stryker on net10.0.)
  • Pipeline-head cancellationAsAsyncEnumerable exposes the head with no sink downstream, isolating its ThrowIfCancellationRequested so a real test kills it.

Other real kills

  • StageList aggregates every stage disposal failure (two-throwing-stage test).
  • Dispose-stages coverage for the cancellation / progress / progress+cancellation Extract() overloads (each builds its own stages array).

Still documented as equivalent (no test can kill them in ANY environment)

  • base Dispose(bool) / EnsureStarted inert blocks — _disposed has no reader; the CompareExchange already guards the single assignment.
  • the sink's redundant cancellation check — the head guard (tested above) is always upstream.
  • Report.EstimatedRemaining's >= overflow boundary — the exact-equality case is unconstructible.

Honest survivors (deliberately not masked)

SystemProgressTimer start/stop/dispose + the base timer.Start — real behaviour observable only through wall-clock timing; a deterministic assertion would reintroduce the flakiness removed in #250.

Optional follow-up (not in this PR)

Adding ThrowIfDisposed to the public methods would give _disposed a real reader — making its guard testable and using a disposed component throw ObjectDisposedException. That is a public-contract change, so it's left as a separate decision.

Verification

  • dotnet test net10.0: 362 passing
  • Stryker (net10.0): 96.86%, clears the break: 80 gate.

Chris-Wolfgang and others added 2 commits July 23, 2026 09:00
Two more real kills plus documentation of the provably-equivalent mutants
that no test can kill.

Real kills (tests):
- StageList aggregates EVERY stage disposal failure (two throwing stages)
  — kills the errors ??= List accumulation mutant.
- Extraction honours a pre-cancelled token even when the source ignores it
  — kills the explicit ThrowIfCancellationRequested in the pipeline head.

Documented equivalent mutants (// Stryker disable once, base classes):
- GC.SuppressFinalize — the types declare no finalizer, so suppressing (or
  not) finalization is unobservable.
- the _disposed guard — the flag has no other reader (nothing throws
  ObjectDisposedException), so it guards an inert base body.
- the EnsureStarted early return — the CompareExchange already guards the
  single assignment, so skipping the fast-path return changes nothing.

Score 86.61% -> 90.12% (over the 90% "high" band). Remaining survivors are
ConfigureAwait(false) flips (differ only under a SynchronizationContext, absent
in the test host), timing-only SystemProgressTimer paths (see #250), and a few
defensive double-guards — left as honest survivors rather than masked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 23, 2026 13:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BenchmarkDotNet

Details
Benchmark suite Current: cb57b6e Previous: 732efdd Ratio
Wolfgang.Etl.Abstractions.Benchmarks.ExtractorBenchmarks.Extract_NoProgress(RecordCount: 1000) 32164.728556315105 ns (± 46.50357438138206) 32688.512502034504 ns (± 213.01288290577438) 0.98
Wolfgang.Etl.Abstractions.Benchmarks.ExtractorBenchmarks.Extract_WithProgress(RecordCount: 1000) 33649.98239135742 ns (± 37.91301601949685) 34913.1728108724 ns (± 92.90601829761779) 0.96
Wolfgang.Etl.Abstractions.Benchmarks.ExtractorBenchmarks.Extract_NoProgress(RecordCount: 100000) 3074153.8658854165 ns (± 1982.177681905804) 3227835.359375 ns (± 5331.793855491133) 0.95
Wolfgang.Etl.Abstractions.Benchmarks.ExtractorBenchmarks.Extract_WithProgress(RecordCount: 100000) 3331846.2526041665 ns (± 9639.082810928301) 3403880.4401041665 ns (± 29793.796946240178) 0.98
Wolfgang.Etl.Abstractions.Benchmarks.PipelineBenchmarks.FluentPipeline(RecordCount: 1000) 29344.70556640625 ns (± 71.28473085476745) 31655.328531901043 ns (± 117.35331683022335) 0.93
Wolfgang.Etl.Abstractions.Benchmarks.PipelineBenchmarks.ManualComposition(RecordCount: 1000) 29923.20571390788 ns (± 123.71479060734599) 32588.058044433594 ns (± 149.83187729728172) 0.92
Wolfgang.Etl.Abstractions.Benchmarks.PipelineBenchmarks.BaseClassComposition(RecordCount: 1000) 74163.04435221355 ns (± 182.96070530731157) 70625.31201171875 ns (± 1269.4931495812575) 1.05
Wolfgang.Etl.Abstractions.Benchmarks.PipelineBenchmarks.FluentPipeline(RecordCount: 100000) 2897608.9244791665 ns (± 1272.7740507314295) 3210122.2981770835 ns (± 297.86708111041185) 0.90
Wolfgang.Etl.Abstractions.Benchmarks.PipelineBenchmarks.ManualComposition(RecordCount: 100000) 2918150.74609375 ns (± 3956.812856523811) 3240046.2122395835 ns (± 4670.818271378057) 0.90
Wolfgang.Etl.Abstractions.Benchmarks.PipelineBenchmarks.BaseClassComposition(RecordCount: 100000) 6768623.869791667 ns (± 13483.001524824824) 6896450.057291667 ns (± 10313.052399783468) 0.98

This comment was automatically generated by workflow using github-action-benchmark.

Kills the remaining reachable survivors and documents the rest as
provable equivalents.

Real kills (tests):
- Dispose-stages coverage for the cancellation, progress, and
  progress+cancellation Extract() factory overloads — each has its own
  stages array (kills the three array-initializer mutants that the plain
  IExtractAsync overload's test never reached).

Documented equivalent mutants (// Stryker disable, with inline reasons):
- base-class Dispose(bool)/EnsureStarted blocks — the bodies are inert
  (_disposed has no other reader; CompareExchange already guards the
  single assignment), so removing a block/guard/assignment is unobservable.
- ConfigureAwait(false) flips — no SynchronizationContext in the test host.
- the paired cancellation ThrowIfCancellationRequested checks in the
  pipeline head and sink — mutually redundant defence-in-depth.
- Report.EstimatedRemaining's >= overflow boundary — unreachable exact
  equality.

Score 86.61% -> 96.68%. The 10 remaining survivors are all real,
timing-only behaviour in SystemProgressTimer and the base timer.Start
call — deliberately left as honest survivors rather than masked, since a
deterministic unit assertion would reintroduce the flakiness removed in #250.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Chris-Wolfgang Chris-Wolfgang changed the title test: raise mutation score to 90.12% — kills + documented equivalents (#205) test: raise mutation score to 96.68% — kills + documented equivalents (#205) Jul 23, 2026
…205)

Replaces the ~10 `// Stryker disable` annotations on the ConfigureAwait(false)
awaits with a real test that proves the behaviour they protect: the library
never marshals a continuation back onto the caller's SynchronizationContext
(the property that keeps WPF / WinForms / legacy-ASP.NET consumers safe from
the sync-over-async deadlock).

ConfigureAwait(false)->(true) is invisible to a headless test host (no
context), which is why those mutants survived — but it is NOT equivalent, so
disabling them was the weakest part of the previous pass. New
ConfigureAwaitContextTests installs a counting SynchronizationContext, starts
each operation while it is current, then asserts nothing was posted back.
Six tests cover the streaming and disposal awaits across the EtlPipeline sink,
the DisposingOwned wrapper, the fluent Pipeline/StageList, and LoaderBase's
progress path. A ConfigureAwait(true) mutation posts a continuation to the
context and fails the assertion.

Result: all ConfigureAwait mutants are now Killed by real tests (16 killed,
1 timeout, 0 survived); the 10 annotations are gone. Score 96.68% -> 97.11%.
The remaining 9 survivors are the timing-only SystemProgressTimer / timer.Start
behaviour, still left as honest survivors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Chris-Wolfgang Chris-Wolfgang changed the title test: raise mutation score to 96.68% — kills + documented equivalents (#205) test: raise mutation score to 97.11% — real kills + SynchronizationContext test (#205) Jul 23, 2026
… tests (#205)

Continues turning "disabled" mutants into genuinely tested ones. Removes 7
more // Stryker disable annotations, leaving only mutants that are provably
equivalent in every environment.

- GC.SuppressFinalize (6 mutants, Dispose + DisposeAsync across the 3 base
  classes): FinalizationSuppressionTests derives a finalizer-bearing double,
  disposes it, forces a GC, and asserts the finalizer did NOT run. Dropping
  SuppressFinalize lets it run and fails. (The base types ship no finalizer,
  so the normal cross-TFM run always passes deterministically — finalization
  is suppressed and never runs; the mutant is only exercised under Stryker.)
- Pipeline-head cancellation (EtlPipelineImpl.CountExtracted): AsAsyncEnumerable
  exposes the head with no sink downstream, so its ThrowIfCancellationRequested
  is the sole guard — AsAsyncEnumerable_honours_cancellation_at_the_head kills
  the mutant that the sink's paired guard was masking.

Still documented as equivalent (no test can kill them in any environment):
the base Dispose(bool)/EnsureStarted inert blocks (_disposed has no reader; the
CompareExchange already guards the write), the sink's redundant cancellation
check (the head guard is always upstream), and Report's unreachable >= overflow
boundary.

Score ~97% (96.86%; the small delta vs the prior run is timeout variance from
the GC tests, not a regression — 7 mutants moved from disabled to killed). 362
tests pass. 10 survivors remain, all timing-only SystemProgressTimer/timer.Start.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Chris-Wolfgang Chris-Wolfgang changed the title test: raise mutation score to 97.11% — real kills + SynchronizationContext test (#205) test: raise mutation score to ~97% — real tests over disables (#205) Jul 23, 2026
@Chris-Wolfgang
Chris-Wolfgang merged commit dd137a4 into vNext Jul 23, 2026
17 checks passed
@Chris-Wolfgang
Chris-Wolfgang deleted the test/205-raise-mutation-score branch July 23, 2026 18:31
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