Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#region Usings declarations

using JetBrains.Annotations;

using NFluent;

#endregion

namespace FirstClassErrors.Testing.UnitTests;

/// <summary>
/// Tests over the <i>shape</i> of the values the arbitrary-value factories return.
/// </summary>
/// <remarks>
/// The shape is the whole point of these factories: <c>Any detailed message 7F3A9C.</c> 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.
/// </remarks>
[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<string> 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<ErrorOrigin> drawn = [];
for (int i = 0; i < 200; i++) { drawn.Add(ErrorOriginFactory.Any()); }

Check.That(drawn).Not.HasSize(1);
}

}
105 changes: 105 additions & 0 deletions FirstClassErrors.Testing.UnitTests/AssertionArgumentGuardTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#region Usings declarations

using JetBrains.Annotations;

using NFluent;

#endregion

namespace FirstClassErrors.Testing.UnitTests;

/// <summary>
/// Guard tests for the arguments every public entry point of the testing package documents as
/// <see cref="ArgumentNullException" />. They exist because the promise is made in the XML documentation and was,
/// until now, made nowhere else: deleting any of these <c>throw</c> statements left the whole suite green.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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<ArgumentNullException>();
}

[Fact(DisplayName = "ShouldFail on a null Outcome throws ArgumentNullException.")]
public void ShouldFailRejectsANullOutcome() {
Outcome outcome = null!;

Check.ThatCode(() => outcome.ShouldFail())
.Throws<ArgumentNullException>();
}

[Fact(DisplayName = "ShouldSucceed on a null Outcome<T> throws ArgumentNullException.")]
public void ShouldSucceedRejectsANullGenericOutcome() {
Outcome<int> outcome = null!;

Check.ThatCode(() => outcome.ShouldSucceed())
.Throws<ArgumentNullException>();
}

[Fact(DisplayName = "ShouldFail on a null Outcome<T> throws ArgumentNullException.")]
public void ShouldFailRejectsANullGenericOutcome() {
Outcome<int> outcome = null!;

Check.ThatCode(() => outcome.ShouldFail())
.Throws<ArgumentNullException>();
}

[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<ArgumentNullException>();
Check.ThatCode(() => assertion.WithCode((ErrorCode)null!))
.Throws<ArgumentNullException>();
}

[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<ArgumentNullException>();
}

[Fact(DisplayName = "Clock.Use rejects a null clock.")]
public void ClockUseRejectsANullClock() {
Check.ThatCode(() => Clock.Use(null!))
.Throws<ArgumentNullException>();
}

[Fact(DisplayName = "InstanceIds.Use rejects a null identifier source.")]
public void InstanceIdsUseRejectsANullSource() {
Check.ThatCode(() => InstanceIds.Use(null!))
.Throws<ArgumentNullException>();
}

}
160 changes: 160 additions & 0 deletions FirstClassErrors.Testing.UnitTests/AssertionFailureMessageTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
#region Usings declarations

using JetBrains.Annotations;

using NFluent;

#endregion

namespace FirstClassErrors.Testing.UnitTests;

/// <summary>
/// Tests over the <i>content</i> of the messages the assertions produce when an expectation is not met.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// Each test therefore names the two facts a reader needs: what was expected, and what was actually there.
/// </para>
/// </remarks>
[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<ErrorContextBuilder> context) {
return DomainError.Create(ErrorCodeFactory.Any(), DiagnosticMessageFactory.Any(), context)
.WithPublicMessage(ShortMessageFactory.Any());
}

private static string MessageOf(Action assertion) {
return Assert.Throws<OutcomeAssertionException>(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<string> network = ErrorContextKey.Create<string>("AssertionMessageNetwork", "The card network.");
ErrorContextKey<string> issuer = ErrorContextKey.Create<string>("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<string> network = ErrorContextKey.Create<string>("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<string> network = ErrorContextKey.Create<string>("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<string>.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");
}

}
3 changes: 3 additions & 0 deletions FirstClassErrors.Testing/ErrorAssertion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ public ErrorAssertion WithShortMessage(string expected) {
/// <exception cref="ArgumentNullException">Thrown when <paramref name="key" /> is <c>null</c>.</exception>
/// <exception cref="OutcomeAssertionException">Thrown when no entry with that key is present.</exception>
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)) {
Expand Down
4 changes: 4 additions & 0 deletions FirstClassErrors.Testing/InstanceIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ public static IDisposable UseFixed(Guid id) {
/// <returns>A scope that restores the default (random) identifier when disposed.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="next" /> is <c>null</c>.</exception>
public static IDisposable Use(Func<Guid> 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);
Expand Down
Loading