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 } } }