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");
+ }
+
+}
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);
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!);
+ }
+ }
+
+}
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
}
}
}