test: raise mutation score to ~97% — real tests over disables (#205) - #317
Merged
Conversation
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>
Contributor
There was a problem hiding this comment.
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>
…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>
… 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>
This was referenced Jul 24, 2026
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)—ConfigureAwaitContextTestsinstalls a countingSynchronizationContextand asserts the library never posts a continuation back (the deadlock-safety property for WPF/WinForms/legacy-ASP.NET consumers). AConfigureAwait(true)mutation posts and fails. All ConfigureAwait mutants killed.GC.SuppressFinalize—FinalizationSuppressionTestsderives a finalizer-bearing double, disposes it, forces a GC, and asserts the finalizer did not run. DroppingSuppressFinalizelets 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.)AsAsyncEnumerableexposes the head with no sink downstream, isolating itsThrowIfCancellationRequestedso a real test kills it.Other real kills
Extract()overloads (each builds its ownstagesarray).Still documented as equivalent (no test can kill them in ANY environment)
Dispose(bool)/EnsureStartedinert blocks —_disposedhas no reader; theCompareExchangealready guards the single assignment.Report.EstimatedRemaining's>=overflow boundary — the exact-equality case is unconstructible.Honest survivors (deliberately not masked)
SystemProgressTimerstart/stop/dispose + the basetimer.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
ThrowIfDisposedto the public methods would give_disposeda real reader — making its guard testable and using a disposed component throwObjectDisposedException. That is a public-contract change, so it's left as a separate decision.Verification
dotnet testnet10.0: 362 passingbreak: 80gate.