From 750b3f8d0b0b7024bca097c93da1ed839e444787 Mon Sep 17 00:00:00 2001 From: Thomas Bruderer Date: Tue, 27 Oct 2026 18:33:49 +0100 Subject: [PATCH 1/2] Add the Testing with xUnit chapter to the book MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents Funcky.Xunit (xUnit.net 2.x) and Funcky.Xunit.v3 (3.x): the Assert.Some/None/Ok/Error/Left/Right members with their expected-value and returning overloads, the exact-type Error overload, the failure message format, why there is no Assert.True(option.IsSome), the analyzer rules λ1101 and λ1203, ToTheoryData and the v3 serializers with their Register*Serializer assembly attributes. Closes #1017 Co-Authored-By: Claude Fable 5.1 --- Documentation/src/SUMMARY.md | 1 + Documentation/src/xunit.md | 196 +++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 Documentation/src/xunit.md diff --git a/Documentation/src/SUMMARY.md b/Documentation/src/SUMMARY.md index 8e6ccdd2..983e8ff1 100644 --- a/Documentation/src/SUMMARY.md +++ b/Documentation/src/SUMMARY.md @@ -39,6 +39,7 @@ * [Number Extensions](./number-extensions.md) * [Stream Extensions](./stream-extensions.md) * [Nullable Member Extensions](./nullable-members.md) +* [Testing with xUnit](./xunit.md) * [Analyzer Rules](./analyzer-rules/analyzer-rules.md) * [λ0001](./analyzer-rules/λ0001.md) * [λ0003](./analyzer-rules/λ0003.md) diff --git a/Documentation/src/xunit.md b/Documentation/src/xunit.md new file mode 100644 index 00000000..20d97144 --- /dev/null +++ b/Documentation/src/xunit.md @@ -0,0 +1,196 @@ +# Testing with xUnit + +A test that receives an `Option` has the same question as any other code: how do I get the value out? +In production code the answer is [`Select`, `Match` and friends](./option.md#how-do-i-get-the-value-out). In a test the +answer is an assertion, because a test *wants* to fail when the case is wrong. Funcky.Xunit provides those assertions +for `Option`, `Result` and `Either`, in the same shape as xunit's own `Assert.Single` or `Assert.IsType`: +assert the case, get the content back. + +## Which package + +| xUnit.net version | Package | +|-------------------|-----------------------------------------------------------------------| +| 2.x | [`Funcky.Xunit`](https://www.nuget.org/packages/Funcky.Xunit) | +| 3.x | [`Funcky.Xunit.v3`](https://www.nuget.org/packages/Funcky.Xunit.v3) | + +Both packages share the assertions below and differ only in the theory-data helpers at the end of this chapter. +The assertions are static extension members of xunit's `Assert`, declared in `Funcky.Extensions.AssertExtensions`, +so they appear in the same completion list as `Assert.Equal`: + +```cs +using Funcky.Extensions; + +Assert.Some(42, FindUser(id).Select(user => user.Age)); +``` + +Extension members need the C# 14 compiler (.NET 10 SDK). Older compilers use the same assertions as methods of +the `Funcky.FunctionalAssert` class, `FunctionalAssert.Some(42, option)`. The two forms are the same code; the +`Assert` members are the implementation and `FunctionalAssert` forwards to them. If both are available, +[λ1203](./analyzer-rules/λ1203.md) asks for the `Assert` form and comes with a code fix. + +## The assertions + +Each monad gets one assertion per case. The assertion for the case that carries a value comes in two overloads: +one that takes the expected value and compares it, and one that returns the value so you can continue asserting on it. + +| Monad | Case | Expected-value overload | Returning overload | +|------------------|-----------|-------------------------------------------|-------------------------------------------------------| +| `Option` | `Some` | `Assert.Some(expected, option)` | `T Assert.Some(option)` | +| `Option` | `None` | `Assert.None(option)` | | +| `Result` | `Ok` | `Assert.Ok(expected, result)` | `T Assert.Ok(result)` | +| `Result` | `Error` | | `Exception Assert.Error(result)` | +| `Result` | `Error` | | `TException Assert.Error(result)` | +| `Either` | `Left` | `Assert.Left(expected, either)` | `L Assert.Left(either)` | +| `Either` | `Right` | `Assert.Right(expected, either)` | `R Assert.Right(either)` | + +```cs +[Fact] +public void ParsesTheAnswer() + => Assert.Some(42, "42".ParseInt32OrNone()); + +[Fact] +public void RejectsText() + => Assert.None("forty-two".ParseInt32OrNone()); + +[Fact] +public void PlacesTheOrder() +{ + // The returning overload: unwrap, then assert on the content. + var order = Assert.Ok(PlaceOrder(customerId, productId, quantity: 3)); + Assert.Equal(3, order.Quantity); + Assert.NotEqual(default, order.Id); +} + +[Fact] +public void RejectsUnknownCustomers() +{ + // Error asserts the exact exception type and returns it typed, + // the analogue of Assert.Throws. + var exception = Assert.Error(PlaceOrder("nobody", productId, quantity: 3)); + Assert.Equal("nobody", exception.CustomerId); +} + +[Fact] +public void ValidatesTheInput() + => Assert.Left("quantity must be positive", Validate(quantity: 0)); +``` + +`Assert.Error(result)` without type arguments accepts any exception and returns it as `Exception`. +`Assert.Error(result)` requires the exception to be of *exactly* `TException`, not a subtype, which is +the same rule `Assert.Throws` applies to thrown exceptions. The second type argument is the `T` of the +`Result`; C# cannot infer it when only one type argument is given, so both are spelled out. + +The expected value is compared with `Assert.Equal`, so the same equality xunit uses everywhere applies, including +its structural comparison of collections. + +### Prefer the expected-value overload + +When the test only checks for equality, do not unwrap and compare in two steps: + +```cs +// Two assertions, two places to read. +Assert.Equal(42, Assert.Some(option)); + +// One assertion, and a failure message that shows the whole option. +Assert.Some(42, option); +``` + +[λ1101](./analyzer-rules/λ1101.md) flags the first form and rewrites it to the second. The single call is not only +shorter: the assertion knows the monad it unwrapped, so a failing test reports `Expected: Some(42)` and +`Actual: None` instead of a `NullReferenceException` or a generic `Assert.Equal` failure. + +## How a failure reads + +A failed assertion throws an `XunitException`, so the test runner shows it like any other failed assertion. The +message names the assertion and prints both sides in the notation the monads use in `ToString`: + +```text +FunctionalAssert.Some() Failure: Values differ +Expected: Some(10) +Actual: None +``` + +```text +FunctionalAssert.Ok() Failure: Values differ +Expected: Ok(10) +Actual: Error(System.InvalidOperationException: "boom") +``` + +```text +FunctionalAssert.Error() Failure: Values differ +Expected: Error(ArgumentException) +Actual: Error(InvalidOperationException) +``` + +The returning overloads cannot know the expected content and print `Some(...)`, `Ok(...)`, `Error(...)`, +`Left(...)` or `Right(...)` as the expected side. The label says `FunctionalAssert` in both forms, so a message +looks the same whichever receiver the test used. + +The assertion's own frames are hidden from the stack trace (`[StackTraceHidden]` on .NET 6 and later, a rethrow that +erases the frames elsewhere), so the first line of the trace is the test that failed. + +## Why there is no `Assert.True(option.IsSome)` + +The [introduction](./introduction.md#the-one-idea-behind-funcky) explains that Funcky's monads have no `IsSome`, no +`HasValue` and no `Value`. Tests are the place where that is most tempting to miss, because a test legitimately wants +to look inside. These assertions are the answer: they *are* the test-side way to look inside, and they are better +than a boolean check would be. + +`Assert.True(option.IsSome)` would tell you that the option was empty and nothing else. `Assert.Some(42, option)` +tells you that it was `None` when `Some(42)` was expected, or that it was `Some(41)`. And because the returning +overload hands you the content, there is never a reason to read it a second time through a `Value` property; the +assertion already proved the case, and the type system carries that proof to the next line. + +## Theory data + +### `ToTheoryData` (xUnit.net 2.x) + +xunit 2.x theories take their rows from a `TheoryData<…>`, which is filled with repeated `Add` calls. Funcky.Xunit +adds `ToTheoryData` on any `IEnumerable`, and on sequences of `Tuple` and `ValueTuple` with up to ten components, +so the rows can come from a LINQ query: + +```cs +[Theory] +[MemberData(nameof(SquaresUpToTen))] +public void SquaresArePositive(int number, int square) + => Assert.True(square >= number); + +public static TheoryData SquaresUpToTen() + => Enumerable.Range(0, 10) + .Select(number => (number, number * number)) + .ToTheoryData(); +``` + +Each tuple becomes one row, each component one parameter. The extension lives in `Funcky.Extensions` and is not +part of Funcky.Xunit.v3: xunit 3.x `TheoryData` accepts a collection expression, `TheoryData rows = [1, 2, 3];`, +which covers the same need. + +### `Option`, `Either` and `Unit` as theory data (xUnit.net 3.x) + +xunit 3.x only accepts theory-data values it can serialize, so that each row shows up as its own test in the +runner and can be rerun on its own. Funcky.Xunit.v3 ships serializers for `Option`, `Either` and `Unit`, +so these types can be theory parameters directly: + +```cs +[Theory] +[MemberData(nameof(Options))] +public void SelectingTheIdentityChangesNothing(Option option) + => Assert.Equal(option, option.Select(value => value)); + +public static TheoryData> Options() + => [Option.None, Option.Some(10), Option.Some(20)]; +``` + +An `Option` or `Either` is serializable when its content is serializable by xunit; `None` always is. + +The serializers register themselves. The package's build targets add the three assembly attributes +`[assembly: Funcky.Xunit.RegisterOptionSerializer]`, `[assembly: Funcky.Xunit.RegisterEitherSerializer]` and +`[assembly: Funcky.Xunit.RegisterUnitSerializer]` to every project that has `IsTestProject` set to `true`, which +`Microsoft.NET.Test.Sdk` does. For a project that does not, set the MSBuild property `FunckyRegisterXunitSerializers` to +`true`; set it to `false` to opt out and add the attributes by hand where you need them. + +## Where to go next + +* [Option Monad](./option.md), [Result Monad](./result.md) and [Either Monad](./either.md) describe the types the + assertions unwrap. +* [λ1101](./analyzer-rules/λ1101.md) and [λ1203](./analyzer-rules/λ1203.md) are the two analyzer rules for test code. From 711800937014f5342591adb20a225f559ccf44b1 Mon Sep 17 00:00:00 2001 From: Thomas Bruderer Date: Wed, 28 Oct 2026 17:50:24 +0100 Subject: [PATCH 2/2] Correct the xunit chapter after the Copilot review Assert.Some(option) fails None with an assertion failure of its own, so the gain of the expected-value overload is the expected content in the message and the monad context when the values differ. Only .NET 6 and later hide the assertion frame; the rethrow on older frameworks leaves the assertion method as the first frame. xunit 3 runs theories with non-serializable data too, serialization buys per-row discovery. Co-Authored-By: Claude Fable 5.1 --- Documentation/src/xunit.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/Documentation/src/xunit.md b/Documentation/src/xunit.md index 20d97144..5389d913 100644 --- a/Documentation/src/xunit.md +++ b/Documentation/src/xunit.md @@ -96,8 +96,10 @@ Assert.Some(42, option); ``` [λ1101](./analyzer-rules/λ1101.md) flags the first form and rewrites it to the second. The single call is not only -shorter: the assertion knows the monad it unwrapped, so a failing test reports `Expected: Some(42)` and -`Actual: None` instead of a `NullReferenceException` or a generic `Assert.Equal` failure. +shorter, it also fails better. Both forms report `None` as an assertion failure, but only the single call knows what +was expected and prints `Expected: Some(42)` where the two-step form can only say `Some(...)`. And when the option is +`Some(41)`, the two-step form fails in `Assert.Equal` with `41` against `42` and no trace of the monad, while the single +call reports `Expected: Some(42)` and `Actual: Some(41)`. ## How a failure reads @@ -126,8 +128,9 @@ The returning overloads cannot know the expected content and print `Some(...)`, `Left(...)` or `Right(...)` as the expected side. The label says `FunctionalAssert` in both forms, so a message looks the same whichever receiver the test used. -The assertion's own frames are hidden from the stack trace (`[StackTraceHidden]` on .NET 6 and later, a rethrow that -erases the frames elsewhere), so the first line of the trace is the test that failed. +On .NET 6 and later the assertion's own frames are hidden from the stack trace with `[StackTraceHidden]`, so the +first line of the trace is the test that failed. On older frameworks a rethrow erases the internal frames of the +monad and the lambdas, but the assertion method itself remains the first frame, with the test right below it. ## Why there is no `Assert.True(option.IsSome)` @@ -167,9 +170,10 @@ which covers the same need. ### `Option`, `Either` and `Unit` as theory data (xUnit.net 3.x) -xunit 3.x only accepts theory-data values it can serialize, so that each row shows up as its own test in the -runner and can be rerun on its own. Funcky.Xunit.v3 ships serializers for `Option`, `Either` and `Unit`, -so these types can be theory parameters directly: +xunit 3.x runs a theory with any data, but only rows it can serialize are discovered individually: each shows up +as its own test in the runner and can be rerun on its own. A row with a value xunit cannot serialize collapses the +whole theory into a single test case. Funcky.Xunit.v3 ships serializers for `Option`, `Either` and `Unit`, +so these types work as theory parameters without losing per-row discovery: ```cs [Theory]