From 9b28c14537824f454e028fcf0323db3bc2aa6a58 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 17:08:18 +0000 Subject: [PATCH 1/4] test(testing): assert the contracts the testing package documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../ArbitraryValueFactoryShapeTests.cs | 70 ++++++++ .../AssertionArgumentGuardTests.cs | 105 ++++++++++++ .../AssertionFailureMessageTests.cs | 160 ++++++++++++++++++ 3 files changed, 335 insertions(+) create mode 100644 FirstClassErrors.Testing.UnitTests/ArbitraryValueFactoryShapeTests.cs create mode 100644 FirstClassErrors.Testing.UnitTests/AssertionArgumentGuardTests.cs create mode 100644 FirstClassErrors.Testing.UnitTests/AssertionFailureMessageTests.cs diff --git a/FirstClassErrors.Testing.UnitTests/ArbitraryValueFactoryShapeTests.cs b/FirstClassErrors.Testing.UnitTests/ArbitraryValueFactoryShapeTests.cs new file mode 100644 index 00000000..592a6bb4 --- /dev/null +++ b/FirstClassErrors.Testing.UnitTests/ArbitraryValueFactoryShapeTests.cs @@ -0,0 +1,70 @@ +#region Usings declarations + +using JetBrains.Annotations; + +using NFluent; + +#endregion + +namespace FirstClassErrors.Testing.UnitTests; + +/// +/// Tests over the shape of the values the arbitrary-value factories return. +/// +/// +/// The shape is the whole point of these factories: Any detailed message 7F3A9C. announces itself as +/// arbitrary in a failure message, where a bare random string would read as a value someone chose. Nothing asserted +/// that shape, so the recognisable prefix and the trailing period could both vanish unnoticed — and the factories +/// would keep passing while losing the only property that distinguishes them from a plain random draw. +/// +[TestSubject(typeof(DetailedMessageFactory))] +[TestSubject(typeof(DiagnosticMessageFactory))] +[TestSubject(typeof(ShortMessageFactory))] +[TestSubject(typeof(ErrorOriginFactory))] +public sealed class ArbitraryValueFactoryShapeTests { + + [Fact(DisplayName = "DetailedMessageFactory.Any announces itself and ends as a sentence.")] + public void DetailedMessageIsRecognisableAsArbitrary() { + string message = DetailedMessageFactory.Any(); + + Check.That(message).StartsWith("Any detailed message "); + Check.That(message).EndsWith("."); + } + + [Fact(DisplayName = "DiagnosticMessageFactory.Any announces itself and ends as a sentence.")] + public void DiagnosticMessageIsRecognisableAsArbitrary() { + string message = DiagnosticMessageFactory.Any(); + + Check.That(message).StartsWith("Any diagnostic message "); + Check.That(message).EndsWith("."); + } + + [Fact(DisplayName = "ShortMessageFactory.Any announces itself and ends as a sentence.")] + public void ShortMessageIsRecognisableAsArbitrary() { + string message = ShortMessageFactory.Any(); + + Check.That(message).StartsWith("Any short message "); + Check.That(message).EndsWith("."); + } + + [Fact(DisplayName = "The message factories vary their arbitrary part between calls.")] + public void MessageFactoriesVaryTheirArbitraryPart() { + // A factory pinned to a constant would still satisfy the shape checks above while defeating the purpose of + // drawing a value at all: two errors built from it would be indistinguishable in a report. + HashSet drawn = []; + for (int i = 0; i < 50; i++) { drawn.Add(DetailedMessageFactory.Any()); } + + Check.That(drawn).Not.HasSize(1); + } + + [Fact(DisplayName = "ErrorOriginFactory.Any draws across the origins rather than returning a constant.")] + public void ErrorOriginFactoryDrawsMoreThanOneOrigin() { + // Documented as uniform across all members: a factory collapsed to a single origin — the enum's default being + // the likeliest accident — would pass every test that only reads "some origin". + HashSet drawn = []; + for (int i = 0; i < 200; i++) { drawn.Add(ErrorOriginFactory.Any()); } + + Check.That(drawn).Not.HasSize(1); + } + +} diff --git a/FirstClassErrors.Testing.UnitTests/AssertionArgumentGuardTests.cs b/FirstClassErrors.Testing.UnitTests/AssertionArgumentGuardTests.cs new file mode 100644 index 00000000..f1b3b00e --- /dev/null +++ b/FirstClassErrors.Testing.UnitTests/AssertionArgumentGuardTests.cs @@ -0,0 +1,105 @@ +#region Usings declarations + +using JetBrains.Annotations; + +using NFluent; + +#endregion + +namespace FirstClassErrors.Testing.UnitTests; + +/// +/// Guard tests for the arguments every public entry point of the testing package documents as +/// . They exist because the promise is made in the XML documentation and was, +/// until now, made nowhere else: deleting any of these throw statements left the whole suite green. +/// +/// +/// A guard that nothing asserts is a guard that can be removed by accident. These are cheap, and they are the +/// difference between a documented contract and a comment. +/// +[TestSubject(typeof(OutcomeAssertions))] +[TestSubject(typeof(ErrorAssertion))] +public sealed class AssertionArgumentGuardTests { + + #region Statics members declarations + + private static DomainError AnError() { + return DomainError.Create(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any()) + .WithPublicMessage(ShortMessageFactory.Any()); + } + + #endregion + + [Fact(DisplayName = "ShouldSucceed on a null Outcome throws ArgumentNullException.")] + public void ShouldSucceedRejectsANullOutcome() { + Outcome outcome = null!; + + Check.ThatCode(() => outcome.ShouldSucceed()) + .Throws(); + } + + [Fact(DisplayName = "ShouldFail on a null Outcome throws ArgumentNullException.")] + public void ShouldFailRejectsANullOutcome() { + Outcome outcome = null!; + + Check.ThatCode(() => outcome.ShouldFail()) + .Throws(); + } + + [Fact(DisplayName = "ShouldSucceed on a null Outcome throws ArgumentNullException.")] + public void ShouldSucceedRejectsANullGenericOutcome() { + Outcome outcome = null!; + + Check.ThatCode(() => outcome.ShouldSucceed()) + .Throws(); + } + + [Fact(DisplayName = "ShouldFail on a null Outcome throws ArgumentNullException.")] + public void ShouldFailRejectsANullGenericOutcome() { + Outcome outcome = null!; + + Check.ThatCode(() => outcome.ShouldFail()) + .Throws(); + } + + [Fact(DisplayName = "WithCode rejects a null expected code, whichever overload is called.")] + public void WithCodeRejectsANullExpectedCode() { + ErrorAssertion assertion = Outcome.Failure(AnError()).ShouldFail(); + + Check.ThatCode(() => assertion.WithCode((string)null!)) + .Throws(); + Check.ThatCode(() => assertion.WithCode((ErrorCode)null!)) + .Throws(); + } + + [Fact(DisplayName = "WithCode accepts a matching ErrorCode and returns the same assertion for chaining.")] + public void WithCodeAcceptsAMatchingErrorCode() { + ErrorCode code = ErrorCodeFactory.Any(); + DomainError error = DomainError.Create(code, DiagnosticMessageFactory.Any()).WithPublicMessage(ShortMessageFactory.Any()); + ErrorAssertion assertion = Outcome.Failure(error).ShouldFail(); + + // The ErrorCode overload delegates to the string one; both the guard and the delegation are asserted here. + Check.That(assertion.WithCode(code)).IsSameReferenceAs(assertion); + } + + [Fact(DisplayName = "WithContextEntry rejects a null key.")] + public void WithContextEntryRejectsANullKey() { + ErrorAssertion assertion = Outcome.Failure(AnError()).ShouldFail(); + + Check.ThatCode(() => assertion.WithContextEntry(null!)) + .Throws(); + } + + [Fact(DisplayName = "Clock.Use rejects a null clock.")] + public void ClockUseRejectsANullClock() { + Check.ThatCode(() => Clock.Use(null!)) + .Throws(); + } + + [Fact(DisplayName = "InstanceIds.Use rejects a null identifier source.")] + public void InstanceIdsUseRejectsANullSource() { + Check.ThatCode(() => InstanceIds.Use(null!)) + .Throws(); + } + +} diff --git a/FirstClassErrors.Testing.UnitTests/AssertionFailureMessageTests.cs b/FirstClassErrors.Testing.UnitTests/AssertionFailureMessageTests.cs new file mode 100644 index 00000000..a14f4b33 --- /dev/null +++ b/FirstClassErrors.Testing.UnitTests/AssertionFailureMessageTests.cs @@ -0,0 +1,160 @@ +#region Usings declarations + +using JetBrains.Annotations; + +using NFluent; + +#endregion + +namespace FirstClassErrors.Testing.UnitTests; + +/// +/// Tests over the content of the messages the assertions produce when an expectation is not met. +/// +/// +/// +/// The message is this package's product. An assertion helper that detects the mismatch but describes it badly +/// wastes exactly the minutes it exists to save — and nothing else in the suite looked at these strings: every +/// message could be replaced by an empty one without a single test noticing. +/// +/// +/// Each test therefore names the two facts a reader needs: what was expected, and what was actually there. +/// +/// +[TestSubject(typeof(OutcomeAssertions))] +[TestSubject(typeof(ErrorAssertion))] +public sealed class AssertionFailureMessageTests { + + #region Statics members declarations + + private static DomainError AnError(string diagnostic, string @short) { + return DomainError.Create(ErrorCodeFactory.Any(), diagnostic).WithPublicMessage(@short); + } + + private static DomainError AnErrorWithContext(Action context) { + return DomainError.Create(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any(), context) + .WithPublicMessage(ShortMessageFactory.Any()); + } + + private static string MessageOf(Action assertion) { + return Assert.Throws(assertion).Message; + } + + #endregion + + [Fact(DisplayName = "A mismatching code is reported with both the expected and the actual code.")] + public void MismatchingCodeNamesBothCodes() { + ErrorCode actual = ErrorCode.Create("ACTUAL_CODE"); + DomainError error = DomainError.Create(actual, DiagnosticMessageFactory.Any()).WithPublicMessage(ShortMessageFactory.Any()); + ErrorAssertion assertion = Outcome.Failure(error).ShouldFail(); + + string message = MessageOf(() => assertion.WithCode("EXPECTED_CODE")); + + Check.That(message).Contains("EXPECTED_CODE", "ACTUAL_CODE"); + } + + [Fact(DisplayName = "A mismatching diagnostic message is reported with both the expected and the actual text.")] + public void MismatchingDiagnosticMessageNamesBothTexts() { + ErrorAssertion assertion = Outcome.Failure(AnError("actual diagnostic", ShortMessageFactory.Any())).ShouldFail(); + + string message = MessageOf(() => assertion.WithDiagnosticMessage("expected diagnostic")); + + Check.That(message).Contains("expected diagnostic", "actual diagnostic"); + } + + [Fact(DisplayName = "A mismatching short message is reported with both the expected and the actual text.")] + public void MismatchingShortMessageNamesBothTexts() { + ErrorAssertion assertion = Outcome.Failure(AnError(DiagnosticMessageFactory.Any(), "actual short")).ShouldFail(); + + string message = MessageOf(() => assertion.WithShortMessage("expected short")); + + Check.That(message).Contains("expected short", "actual short"); + } + + [Fact(DisplayName = "An absent context entry is reported with the missing key and the keys that are present.")] + public void AbsentContextEntryNamesTheMissingKeyAndThePresentOnes() { + ErrorContextKey network = ErrorContextKey.Create("AssertionMessageNetwork", "The card network."); + ErrorContextKey issuer = ErrorContextKey.Create("AssertionMessageIssuer", "The card issuer."); + DomainError error = AnErrorWithContext(context => context.Add(network, "VISA").Add(issuer, "ACME")); + ErrorAssertion assertion = Outcome.Failure(error).ShouldFail(); + + string message = MessageOf(() => assertion.WithContextEntry("Absent")); + + Check.That(message).Contains("Absent", "AssertionMessageNetwork", "AssertionMessageIssuer"); + + // The listed keys must be separated rather than run together. Checked on the list itself, not on the whole + // message: the surrounding prose contains commas of its own, so a separator dropped from the join would go + // unnoticed by a check over the full string. + string listed = message[(message.IndexOf("Present keys: ", StringComparison.Ordinal) + "Present keys: ".Length)..]; + Check.That(listed).Contains(", "); + } + + [Fact(DisplayName = "An absent context entry on an error with no context says so explicitly.")] + public void AbsentContextEntryOnAnEmptyContextSaysNone() { + ErrorAssertion assertion = Outcome.Failure(AnError(DiagnosticMessageFactory.Any(), ShortMessageFactory.Any())).ShouldFail(); + + string message = MessageOf(() => assertion.WithContextEntry("Absent")); + + Check.That(message).Contains("(none)"); + } + + [Fact(DisplayName = "A context entry with the wrong value is reported with both values.")] + public void MismatchingContextValueNamesBothValues() { + ErrorContextKey network = ErrorContextKey.Create("AssertionMessageValueNetwork", "The card network."); + DomainError error = AnErrorWithContext(context => context.Add(network, "VISA")); + ErrorAssertion assertion = Outcome.Failure(error).ShouldFail(); + + string message = MessageOf(() => assertion.WithContextEntry("AssertionMessageValueNetwork", "MASTERCARD")); + + Check.That(message).Contains("AssertionMessageValueNetwork", "MASTERCARD", "VISA"); + } + + [Fact(DisplayName = "A context entry expected to be null is reported as null, not as an empty string.")] + public void MismatchingContextValueRendersNullDistinctly() { + ErrorContextKey network = ErrorContextKey.Create("AssertionMessageNullNetwork", "The card network."); + DomainError error = AnErrorWithContext(context => context.Add(network, "VISA")); + ErrorAssertion assertion = Outcome.Failure(error).ShouldFail(); + + string message = MessageOf(() => assertion.WithContextEntry("AssertionMessageNullNetwork", null)); + + // "null" unquoted for the absent value, quoted for the present one: the reader must be able to tell a null + // apart from the four-letter string. + Check.That(message).Contains("null", "\"VISA\""); + } + + [Fact(DisplayName = "Asking for the value of an absent entry fails as an assertion, not as a lookup.")] + public void ValueCheckOnAnAbsentKeyReportsTheAbsence() { + ErrorAssertion assertion = Outcome.Failure(AnError(DiagnosticMessageFactory.Any(), ShortMessageFactory.Any())).ShouldFail(); + + // The two-argument overload delegates to the presence check first; without it the dictionary lookup would + // throw a KeyNotFoundException, which reads as a bug in the test helper rather than as a failed expectation. + string message = MessageOf(() => assertion.WithContextEntry("Absent", "whatever")); + + Check.That(message).Contains("Absent"); + } + + [Fact(DisplayName = "A success where a failure was expected is reported as such.")] + public void UnexpectedSuccessIsReported() { + string message = MessageOf(() => Outcome.Success.ShouldFail()); + + Check.That(message).Contains("failure", "success"); + } + + [Fact(DisplayName = "A success carrying a value where a failure was expected reports that value.")] + public void UnexpectedSuccessReportsTheCarriedValue() { + string message = MessageOf(() => Outcome.Success("the carried value").ShouldFail()); + + Check.That(message).Contains("\"the carried value\""); + } + + [Fact(DisplayName = "A failure where a success was expected reports the code and the diagnostic message.")] + public void UnexpectedFailureReportsCodeAndDiagnostic() { + ErrorCode code = ErrorCode.Create("UNEXPECTED_FAILURE"); + DomainError error = DomainError.Create(code, "the diagnostic text").WithPublicMessage(ShortMessageFactory.Any()); + + string message = MessageOf(() => Outcome.Failure(error).ShouldSucceed()); + + Check.That(message).Contains("UNEXPECTED_FAILURE", "the diagnostic text"); + } + +} From 12a3a37929c3c33b6a0e5cbe6026287e9fa64397 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 17:08:19 +0000 Subject: [PATCH 2/4] chore(testing): record two equivalent mutants where they occur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- FirstClassErrors.Testing/ErrorAssertion.cs | 3 +++ FirstClassErrors.Testing/InstanceIds.cs | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/FirstClassErrors.Testing/ErrorAssertion.cs b/FirstClassErrors.Testing/ErrorAssertion.cs index d05e8af0..52407864 100644 --- a/FirstClassErrors.Testing/ErrorAssertion.cs +++ b/FirstClassErrors.Testing/ErrorAssertion.cs @@ -92,6 +92,9 @@ public ErrorAssertion WithShortMessage(string expected) { /// Thrown when is null. /// Thrown when no entry with that key is present. public ErrorAssertion WithContextEntry(string key) { + // Stryker disable once Statement,Block : equivalent mutant. Removing this guard changes nothing observable — + // the dictionary lookup below throws ArgumentNullException for a null key, with the same parameter name. The + // guard stays because it states the contract where the reader looks for it, not because it changes the outcome. if (key is null) { throw new ArgumentNullException(nameof(key)); } if (!_error.Context.ToNameDictionary().ContainsKey(key)) { diff --git a/FirstClassErrors.Testing/InstanceIds.cs b/FirstClassErrors.Testing/InstanceIds.cs index 37f71236..4ff49c39 100644 --- a/FirstClassErrors.Testing/InstanceIds.cs +++ b/FirstClassErrors.Testing/InstanceIds.cs @@ -38,6 +38,10 @@ public static IDisposable UseFixed(Guid id) { /// A scope that restores the default (random) identifier when disposed. /// Thrown when is null. public static IDisposable Use(Func next) { + // Stryker disable once Statement,Block : equivalent mutant. AmbientInstanceId.Use guards the same argument with + // the same exception and the same parameter name, so removing this one is unobservable. It stays because this is + // the public entry point, and the contract belongs where the caller reads it. Clock.Use is NOT equivalent and + // carries no such comment: it forwards a lambda, never the argument, so its guard is the only one there is. if (next is null) { throw new ArgumentNullException(nameof(next)); } return AmbientInstanceId.Use(next); From bf523aad9d6baf3600f347921d5397f2aafb1edc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 17:08:19 +0000 Subject: [PATCH 3/4] test(justdummies): drive the Reproducible hooks directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../ReproducibleAttributeHookTests.cs | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 JustDummies.Xunit.UnitTests/ReproducibleAttributeHookTests.cs diff --git a/JustDummies.Xunit.UnitTests/ReproducibleAttributeHookTests.cs b/JustDummies.Xunit.UnitTests/ReproducibleAttributeHookTests.cs new file mode 100644 index 00000000..239b391d --- /dev/null +++ b/JustDummies.Xunit.UnitTests/ReproducibleAttributeHookTests.cs @@ -0,0 +1,105 @@ +#region Usings declarations + +using JetBrains.Annotations; + +using NFluent; + +#endregion + +namespace JustDummies.Xunit.UnitTests; + +/// +/// The adapter's hooks, driven directly rather than through the xUnit pipeline. +/// +/// +/// +/// Everything the surrounding suite asserts is observed from inside a decorated test, which is the right +/// way to prove that pinning works — but it leaves the hooks' own defensive behaviour unobserved: the test never +/// sees After run, so it cannot tell whether the scope was closed or merely abandoned, nor what happens +/// when After runs without a matching Before. +/// +/// +/// Both hooks ignore their two parameters entirely, so they are passed as null here: naming a real +/// MethodInfo and a fake IXunitTest would suggest they matter. +/// +/// +[TestSubject(typeof(ReproducibleAttribute))] +public sealed class ReproducibleAttributeHookTests { + + #region Statics members declarations + + private static (int, string) Batch() { + return (Any.Int32().Generate(), Any.String().NonEmpty().Generate()); + } + + #endregion + + [Fact(DisplayName = "A declared seed is the seed the attribute reports.")] + public void ADeclaredSeedIsReadBack() { + // The property is what a reader sets and what the replay snippet echoes; nothing else asserted that setting + // it had any effect at all. + ReproducibleAttribute attribute = new() { Seed = 1234 }; + + Check.That(attribute.Seed).IsEqualTo(1234); + } + + [Fact(DisplayName = "An undeclared seed reads as zero.")] + public void AnUndeclaredSeedReadsAsZero() { + Check.That(new ReproducibleAttribute().Seed).IsEqualTo(0); + } + + [Fact(DisplayName = "The after-hook is a no-op when no scope was opened for it.")] + public void TheAfterHookToleratesAMissingScope() { + // xUnit is not obliged to have run Before — a failure in another hook can skip it — and the after-hook must + // survive that rather than take the whole test run down with a NullReferenceException. + ReproducibleAttribute attribute = new(); + + Check.ThatCode(() => attribute.After(null!, null!)).DoesNotThrow(); + } + + [Fact(DisplayName = "The after-hook closes the scope it opened, releasing the ambient source.")] + public void TheAfterHookClosesTheScope() { + // What seed 555 produces on its second draw. If the scope were left open, the draw after the hook would + // continue that sequence and match; a closed scope hands the ambient source back and it will not. + (int, string) secondOfTheSeededRun; + using (Any.UseSeed(555)) { + Batch(); + secondOfTheSeededRun = Batch(); + } + + ReproducibleAttribute attribute = new() { Seed = 555 }; + attribute.Before(null!, null!); + Batch(); + attribute.After(null!, null!); + + Check.That(Batch()).IsNotEqualTo(secondOfTheSeededRun); + } + + [Fact(DisplayName = "Nested scopes unwind in order, restoring the outer seed.")] + public void NestedScopesUnwindInOrder() { + // The method, class and assembly levels nest, and xUnit closes them in reverse. Closing the inner one must + // restore the outer seed rather than the unpinned source. + ReproducibleAttribute outer = new() { Seed = 111 }; + ReproducibleAttribute inner = new() { Seed = 222 }; + + (int, string) expectedSecondOfOuter; + using (Any.UseSeed(111)) { + Batch(); + expectedSecondOfOuter = Batch(); + } + + outer.Before(null!, null!); + try { + Batch(); + + inner.Before(null!, null!); + Batch(); + inner.After(null!, null!); + + Check.That(Batch()).IsEqualTo(expectedSecondOfOuter); + } finally { + outer.After(null!, null!); + } + } + +} From 5524fe9fed44008ae30e1b4692f6763a74fee6aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 17:08:19 +0000 Subject: [PATCH 4/4] ci(testing): ratchet the two measured thresholds up to their scores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.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. --- build/stryker/justdummies-xunit.json | 6 +++--- build/stryker/testing.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/build/stryker/justdummies-xunit.json b/build/stryker/justdummies-xunit.json index f6f89a2b..b5844574 100644 --- a/build/stryker/justdummies-xunit.json +++ b/build/stryker/justdummies-xunit.json @@ -12,9 +12,9 @@ "json" ], "thresholds": { - "high": 85, - "low": 70, - "break": 50 + "high": 100, + "low": 90, + "break": 80 } } } diff --git a/build/stryker/testing.json b/build/stryker/testing.json index fc74b95d..7cfbd44c 100644 --- a/build/stryker/testing.json +++ b/build/stryker/testing.json @@ -12,9 +12,9 @@ "json" ], "thresholds": { - "high": 80, - "low": 60, - "break": 40 + "high": 100, + "low": 95, + "break": 90 } } }