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
63 changes: 63 additions & 0 deletions FirstClassErrors.PropertyTests/ErrorCodePropertyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#region Usings declarations

using FsCheck;
using FsCheck.Fluent;

using JetBrains.Annotations;

#endregion

namespace FirstClassErrors.PropertyTests;

/// <summary>
/// Property-based tests for <see cref="ErrorCode" />. They assert the invariants that must hold for every
/// valid code rather than for a handful of hand-picked examples: an error code is a verbatim, order-sensitive
/// value that round-trips through its textual representations.
/// </summary>
[TestSubject(typeof(ErrorCode))]
public sealed class ErrorCodePropertyTests {

[Fact(DisplayName = "ErrorCode.Create preserves the code verbatim through ToString (no normalization).")]
public void CreatePreservesCodeThroughToString() {
Prop.ForAll(Generators.NonBlank().ToArbitrary(), code => ErrorCode.Create(code).ToString() == code)
.QuickCheckThrowOnFailure();
}

[Fact(DisplayName = "ErrorCode.Create preserves the code verbatim through the implicit string conversion.")]
public void CreatePreservesCodeThroughImplicitString() {
Prop.ForAll(Generators.NonBlank().ToArbitrary(), code => (string)ErrorCode.Create(code) == code)
.QuickCheckThrowOnFailure();
}

[Fact(DisplayName = "Two ErrorCodes are equal if and only if their codes are ordinally equal.")]
public void EqualityIsOrdinal() {
Gen<string> code = Generators.NonBlank();
var pairs = (from left in code
from right in code
select (left, right)).ToArbitrary();

Prop.ForAll(pairs,
pair => (ErrorCode.Create(pair.left) == ErrorCode.Create(pair.right))
== string.Equals(pair.left, pair.right, StringComparison.Ordinal))
.QuickCheckThrowOnFailure();
}

[Fact(DisplayName = "Codes built from the same string share the same hash code.")]
public void EqualCodesShareHashCode() {
Prop.ForAll(Generators.NonBlank().ToArbitrary(),
code => ErrorCode.Create(code).GetHashCode() == ErrorCode.Create(code).GetHashCode())
.QuickCheckThrowOnFailure();
}

[Fact(DisplayName = "ErrorCode.Create rejects any blank code with an ArgumentException.")]
public void CreateRejectsBlankCodes() {
Prop.ForAll(Generators.Blank().ToArbitrary(), blank => Expect.Throws<ArgumentException>(() => ErrorCode.Create(blank)))
.QuickCheckThrowOnFailure();
}

[Fact(DisplayName = "ErrorCode.Create rejects a null code with an ArgumentException.")]
public void CreateRejectsNullCode() {
Assert.Throws<ArgumentException>(() => ErrorCode.Create(null!));
}

}
95 changes: 95 additions & 0 deletions FirstClassErrors.PropertyTests/ErrorDescriptionPropertyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#region Usings declarations

using FsCheck;
using FsCheck.Fluent;

using JetBrains.Annotations;

#endregion

namespace FirstClassErrors.PropertyTests;

/// <summary>
/// Property-based tests for <see cref="ErrorDescription" />, focusing on its normalization contract: the
/// mandatory messages are trimmed and required to be non-blank, while the optional detailed message collapses
/// to <c>null</c> whenever it is blank.
/// </summary>
[TestSubject(typeof(ErrorDescription))]
public sealed class ErrorDescriptionPropertyTests {

[Fact(DisplayName = "The mandatory messages are trimmed and never keep surrounding whitespace.")]
public void MandatoryMessagesAreTrimmed() {
Gen<string> text = Generators.NonBlank();
var inputs = (from shortMessage in text
from diagnostic in text
select (shortMessage, diagnostic)).ToArbitrary();

Prop.ForAll(inputs,
input => {
ErrorDescription description = new(input.shortMessage, input.diagnostic);

return description.ShortMessage == input.shortMessage.Trim()
&& description.DiagnosticMessage == input.diagnostic.Trim()
&& description.ShortMessage == description.ShortMessage.Trim()
&& description.DiagnosticMessage == description.DiagnosticMessage.Trim();
})
.QuickCheckThrowOnFailure();
}

[Fact(DisplayName = "Re-describing with already-trimmed messages is idempotent.")]
public void NormalizationIsIdempotent() {
Gen<string> text = Generators.NonBlank();
var inputs = (from shortMessage in text
from diagnostic in text
select (shortMessage, diagnostic)).ToArbitrary();

Prop.ForAll(inputs,
input => {
ErrorDescription once = new(input.shortMessage, input.diagnostic);
ErrorDescription twice = new(once.ShortMessage, once.DiagnosticMessage);

return once.ShortMessage == twice.ShortMessage && once.DiagnosticMessage == twice.DiagnosticMessage;
})
.QuickCheckThrowOnFailure();
}

[Fact(DisplayName = "A blank detailed message collapses to null; a non-blank one is trimmed.")]
public void DetailedMessageCollapsesWhenBlank() {
Gen<string> text = Generators.NonBlank();
Gen<string?> optionalDetail = Gen.OneOf(Generators.NonBlank().Select(value => (string?)value),
Generators.Blank().Select(value => (string?)value),
Gen.Constant((string?)null));
var inputs = (from shortMessage in text
from diagnostic in text
from detail in optionalDetail
select (shortMessage, diagnostic, detail)).ToArbitrary();

Prop.ForAll(inputs,
input => {
ErrorDescription description = new(input.shortMessage, input.diagnostic, input.detail);
string? expected = string.IsNullOrWhiteSpace(input.detail) ? null : input.detail.Trim();

return description.DetailedMessage == expected;
})
.QuickCheckThrowOnFailure();
}

[Fact(DisplayName = "A blank short message is rejected with an ArgumentException.")]
public void BlankShortMessageIsRejected() {
Prop.ForAll(Generators.Blank().ToArbitrary(), blank => Expect.Throws<ArgumentException>(() => new ErrorDescription(blank, "diagnostic")))
.QuickCheckThrowOnFailure();
}

[Fact(DisplayName = "A blank diagnostic message is rejected with an ArgumentException.")]
public void BlankDiagnosticMessageIsRejected() {
Prop.ForAll(Generators.Blank().ToArbitrary(), blank => Expect.Throws<ArgumentException>(() => new ErrorDescription("short", blank)))
.QuickCheckThrowOnFailure();
}

[Fact(DisplayName = "A null mandatory message is rejected with an ArgumentNullException.")]
public void NullMandatoryMessageIsRejected() {
Assert.Throws<ArgumentNullException>(() => new ErrorDescription(null!, "diagnostic"));
Assert.Throws<ArgumentNullException>(() => new ErrorDescription("short", null!));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!-- FsCheck drives the property-based (fuzzing-style) tests. The bare `using FsCheck;` in each test file
is also what OpenSSF Scorecard's Fuzzing check looks for to credit C# property-based testing. -->
<PackageReference Include="FsCheck" Version="3.3.3" />
<PackageReference Include="JetBrains.Annotations" Version="2026.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.v3" Version="3.2.2" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\FirstClassErrors\FirstClassErrors.csproj" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

</Project>
198 changes: 198 additions & 0 deletions FirstClassErrors.PropertyTests/OutcomeMonadLawTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
#region Usings declarations

using FsCheck;
using FsCheck.Fluent;

using JetBrains.Annotations;

#endregion

namespace FirstClassErrors.PropertyTests;

/// <summary>
/// Property-based tests asserting that <see cref="Outcome{T}" /> obeys the monad laws and that a failure
/// propagates the original <see cref="Error" /> instance unchanged. These are the richest behavioural
/// invariants in the library, and the ones most worth checking against a wide range of randomly generated
/// pipelines rather than a few hand-written examples.
/// </summary>
/// <remarks>
/// Two outcomes are compared with <see cref="Equivalent" />: successes by their carried value, failures by
/// <b>reference identity</b> of their error. Reference identity is deliberate — the library propagates the
/// very same <see cref="Error" /> instance through <c>Then</c>/<c>To</c>/<c>Recover</c>, and these tests
/// lock that in.
/// </remarks>
[TestSubject(typeof(Outcome<>))]
public sealed class OutcomeMonadLawTests {

[Fact(DisplayName = "Left identity: Success(a).Then(f) is equivalent to f(a).")]
public void LeftIdentity() {
var inputs = (from value in Ints()
from step in Steps()
select (value, step)).ToArbitrary();

Prop.ForAll(inputs,
input => {
Func<int, Outcome<int>> f = ToFunction(input.step);

return Equivalent(Outcome<int>.Success(input.value).Then(f), f(input.value));
})
.QuickCheckThrowOnFailure();
}

[Fact(DisplayName = "Right identity: m.Then(Success) is equivalent to m.")]
public void RightIdentity() {
Prop.ForAll(Outcomes().ToArbitrary(), outcome => Equivalent(outcome.Then(value => Outcome<int>.Success(value)), outcome))
.QuickCheckThrowOnFailure();
}

[Fact(DisplayName = "Associativity: m.Then(f).Then(g) is equivalent to m.Then(x => f(x).Then(g)).")]
public void Associativity() {
var inputs = (from outcome in Outcomes()
from first in Steps()
from second in Steps()
select (outcome, first, second)).ToArbitrary();

Prop.ForAll(inputs,
input => {
Func<int, Outcome<int>> f = ToFunction(input.first);
Func<int, Outcome<int>> g = ToFunction(input.second);

return Equivalent(input.outcome.Then(f).Then(g), input.outcome.Then(value => f(value).Then(g)));
})
.QuickCheckThrowOnFailure();
}

[Fact(DisplayName = "Then on a failure short-circuits and propagates the original error instance.")]
public void ThenPropagatesTheOriginalErrorOnFailure() {
Prop.ForAll(Steps().ToArbitrary(),
step => {
Error error = AnError("origin");
Func<int, Outcome<int>> f = ToFunction(step);
Outcome<int> result = Outcome<int>.Failure(error).Then(f);

return result.IsFailure && ReferenceEquals(result.Error, error);
})
.QuickCheckThrowOnFailure();
}

[Fact(DisplayName = "Functor identity: m.To(x => x) is equivalent to m.")]
public void FunctorIdentity() {
Prop.ForAll(Outcomes().ToArbitrary(), outcome => Equivalent(outcome.To(value => value), outcome))
.QuickCheckThrowOnFailure();
}

[Fact(DisplayName = "To is a special case of Then: m.To(f) equals m.Then(x => Success(f(x))).")]
public void MapIsBindWithSuccess() {
var inputs = (from outcome in Outcomes()
from delta in Ints()
select (outcome, delta)).ToArbitrary();

Prop.ForAll(inputs,
input => Equivalent(input.outcome.To(value => value + input.delta),
input.outcome.Then(value => Outcome<int>.Success(value + input.delta))))
.QuickCheckThrowOnFailure();
}

[Fact(DisplayName = "Recover on a success returns the same instance and never runs the fallback.")]
public void RecoverOnSuccessIsIdentity() {
Prop.ForAll(Ints().ToArbitrary(),
value => {
Outcome<int> success = Outcome<int>.Success(value);
Outcome<int> recovered = success.Recover(_ => Outcome<int>.Failure(AnError("fallback")));

return ReferenceEquals(recovered, success);
})
.QuickCheckThrowOnFailure();
}

[Fact(DisplayName = "Recover on a failure runs the fallback with the original error.")]
public void RecoverOnFailureAppliesTheFallback() {
Prop.ForAll(Ints().ToArbitrary(),
fallbackValue => {
Error error = AnError("origin");
Error? observed = null;

Outcome<int> recovered = Outcome<int>.Failure(error)
.Recover(caught => {
observed = caught;

return Outcome<int>.Success(fallbackValue);
});

return recovered.IsSuccess && recovered.GetResultOrThrow() == fallbackValue && ReferenceEquals(observed, error);
})
.QuickCheckThrowOnFailure();
}

[Fact(DisplayName = "Finally folds a success to onSuccess(value) and a failure to onFailure(error).")]
public void FinallyFoldsBothCases() {
Prop.ForAll(Outcomes().ToArbitrary(),
outcome => {
(bool handledSuccess, int value) folded = outcome.Finally(value => (true, value), _ => (false, 0));

return outcome.IsSuccess ? folded == (true, outcome.GetResultOrThrow()) : folded == (false, 0);
})
.QuickCheckThrowOnFailure();
}

#region Statics members declarations

/// <summary>
/// Bounded integers, kept well inside <see cref="int" /> range so that the additions performed by the
/// generated steps never overflow.
/// </summary>
private static Gen<int> Ints() {
return Gen.Choose(-1_000_000, 1_000_000);
}

/// <summary>
/// Generates the specification of a monadic step: how much it adds, whether it fails, and a seed used to
/// build a distinct error instance when it does.
/// </summary>
private static Gen<(int Add, bool Fail, int Seed)> Steps() {
return from add in Ints()
from fail in ArbMap.Default.GeneratorFor<bool>()
from seed in Ints()
select (Add: add, Fail: fail, Seed: seed);
}

/// <summary>
/// Generates an <see cref="Outcome{T}" /> that is either a success carrying a value or a failure carrying
/// a distinct error instance.
/// </summary>
private static Gen<Outcome<int>> Outcomes() {
return Gen.OneOf(Ints().Select(value => Outcome<int>.Success(value)),
Ints().Select(seed => Outcome<int>.Failure(AnError("outcome" + seed))));
}

/// <summary>
/// Materializes a step specification into a function. A failing step always returns the same captured
/// error instance, so reference-identity propagation can be asserted.
/// </summary>
private static Func<int, Outcome<int>> ToFunction((int Add, bool Fail, int Seed) step) {
Error error = AnError("step" + step.Seed);

return value => step.Fail ? Outcome<int>.Failure(error) : Outcome<int>.Success(value + step.Add);
}

/// <summary>
/// Builds a distinct <see cref="DomainError" /> whose code embeds <paramref name="tag" />.
/// </summary>
private static DomainError AnError(string tag) {
return DomainError.Create(ErrorCode.Create("ERR." + tag), "diagnostic " + tag).WithPublicMessage("summary " + tag);
}

/// <summary>
/// Structural equivalence for two outcomes: successes compare by value, failures by reference identity of
/// their error.
/// </summary>
private static bool Equivalent(Outcome<int> left, Outcome<int> right) {
if (left.IsSuccess && right.IsSuccess) { return left.GetResultOrThrow() == right.GetResultOrThrow(); }
if (left.IsFailure && right.IsFailure) { return ReferenceEquals(left.Error, right.Error); }

return false;
}

#endregion

}
Loading