From 093ec760a2fa917bdbf213fe5674acb6415b38bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Wed, 28 Oct 2026 17:32:58 +0100 Subject: [PATCH] feat: assertions on a Task or ValueTask, and CMTK0003 reports one left unawaited Every assertion of CodoMetis.TypeKit.Testing has a form on a Task and on a ValueTask of what it checks, beside its synchronous form: it awaits the task and answers as the synchronous form does, the same content back and the same message. A task that faults propagates its own exception, and a null task is refused by the parameter's name. An assertion on a task fails the test only through the task it returns, so `service.FindAsync(id).ShouldBeSome();` without await passed whatever the task held, and C# warns only in an async method. CMTK0003 reports such a statement, through ConfigureAwait and in a lambda's body too. It knows the assertions by the Testing assembly's identity, as it knows the Generators assembly, so a consumer's own extension on a pending result stays silent: every reported call is new in 2.3, and no build that worked before breaks. The C20 seed whichever-of-its-types is regenerated against the new FaultAssertions.cs. Closes #87 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + design/roadmap.md | 1 - docs/testing.md | 2 + .../IgnoredOutcomeAnalyzer.cs | 17 ++ src/CodoMetis.TypeKit.Analyzers/README.md | 8 +- .../TypeKitSymbols.cs | 10 + .../FaultAssertions.cs | 70 +++++ .../OptionAssertions.cs | 28 ++ src/CodoMetis.TypeKit.Testing/Outcomes.cs | 3 + .../PatchAssertions.cs | 42 +++ src/CodoMetis.TypeKit.Testing/README.md | 14 + .../ResultAssertions.cs | 96 +++++++ .../AnalyzerTest.cs | 7 + .../CodoMetis.TypeKit.Analyzers.Tests.csproj | 1 + .../IgnoredOutcomeAnalyzerTests.cs | 97 +++++++ .../Testing/TestingSamples.cs | 27 ++ .../AsyncAssertionTests.cs | 244 ++++++++++++++++++ ...ng-shouldhold-whichever-of-its-types.patch | 6 +- 18 files changed, 672 insertions(+), 5 deletions(-) create mode 100644 test/CodoMetis.TypeKit.Testing.Tests/AsyncAssertionTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 19cb7e37..ae4f37d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ section is the GitHub release's notes. ## 2.3.0 — Unreleased +### Added + +- Testing: every assertion works on a `Task` or `ValueTask` of what it checks, `await service.FindAsync(id).ShouldBeSome()`; a task that throws fails the test with its own exception, and an assertion left without `await` is reported (CMTK0003) ([CodoMetis.TypeKit.Testing](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Testing/README.md)). + ## 2.2.0 — 2026-10-18 ### Fixed diff --git a/design/roadmap.md b/design/roadmap.md index 716f8ba0..1d5d0efb 100644 --- a/design/roadmap.md +++ b/design/roadmap.md @@ -30,7 +30,6 @@ and then to 2.3 when 2.2.0 became a consolidation release ([decision 97](adr/009 ([decision 64](adr/0064-fault-name-reaches-client-causes-not-told-apart-one-fault.md)). - A default value-object key over `ulong` generated as a plain one is, which needs a per-key value generator a compiled model can write ([decision 63](adr/0063-char-keys-application-assigned-ulong-documented.md)). -- Testing: async assertions. - `CatchAsync` over a `ValueTask`, and `TraverseAsync` on `Option` and `Patch`. ## Gaps, in any 2.x diff --git a/docs/testing.md b/docs/testing.md index 9b8cb278..8c670f85 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -167,3 +167,5 @@ The analyzers run in test projects as they do everywhere else. Neither is a `default` assigned to the `out` parameter of a `Try` method. - **A dropped `Result` is reported (CMTK0003)**, in a test too. Write `_ = ProductCode.Create(input);` to show the drop is meant. +- **So is an assertion on a task you don't await (CMTK0003).** `service.FindAsync(id).ShouldBeSome();` + without `await` passes whatever the task holds. diff --git a/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs b/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs index af95757e..5d4b154d 100644 --- a/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs +++ b/src/CodoMetis.TypeKit.Analyzers/IgnoredOutcomeAnalyzer.cs @@ -141,6 +141,7 @@ private static void Analyze(OperationAnalysisContext context, TypeKitSymbols sym // task still held, await held.WaitAsync(t), nothing is dropped. var passedOn = tasks.PassedOn(dropped); + var reported = false; foreach (var invocation in Producers(dropped, tasks)) { // The statement's own call keeps the statement's location, and its type unless a @@ -150,9 +151,25 @@ private static void Analyze(OperationAnalysisContext context, TypeKitSymbols sym if (DroppedOutcome(type, invocation, symbols, tasks) is not { } outcome) continue; context.Report(Diagnostic.Create(Rule, (own ? expression : invocation).Syntax.GetLocation(), outcome, invocation.TargetMethod.Name)); + reported |= own; } + + // pending.ShouldBeSuccess(); asserts nothing: the assertion fails the test only through its task. + if (!reported && awaited is null && passedOn is IInvocationOperation assertion && IsPendingAssertion(assertion.TargetMethod, tasks)) + context.Report(Diagnostic.Create(Rule, expression.Syntax.GetLocation(), assertion.Type?.Name ?? "Task", assertion.TargetMethod.Name)); } + /// + /// An assertion of CodoMetis.TypeKit.Testing on a Task or ValueTask of the value it + /// asserts on, await service.FindAsync(id).ShouldBeSome(): it awaits the value and fails the + /// test through the task it returns, so that task dropped unawaited passes whatever the value is. + /// Every such assertion is new in 2.3, so reporting its drop breaks no build that worked before. + /// + private static bool IsPendingAssertion(IMethodSymbol method, Tasks tasks) => + TypeKitSymbols.IsTestingAssembly(method.ContainingAssembly) + && (method.ReducedFrom ?? method).Parameters is { Length: > 0 } parameters + && tasks.Awaited(parameters[0].Type) is not null; + /// /// The calls whose values drops as one: the call itself, or each /// branch's call of a conditional or a switch expression, through implicit conversions, diff --git a/src/CodoMetis.TypeKit.Analyzers/README.md b/src/CodoMetis.TypeKit.Analyzers/README.md index 88816f9c..14351690 100644 --- a/src/CodoMetis.TypeKit.Analyzers/README.md +++ b/src/CodoMetis.TypeKit.Analyzers/README.md @@ -28,7 +28,7 @@ project. The two packages share a version and move together. |---|---|---| | CMTK0001 | Error | `default`, `default(T)`, `new T()`, `new()` and `new T { }` of a value object, an `Option`, a `Result`, or a struct marked `[RequireCustomInitialization]`. Also through a type parameter whose constraints make it one of those. Not reported: a default that is only compared (`id == default`, `id.Equals(default)`, `ThrowIfEqual(id, default)`, `Assert.NotEqual(default, id)`), which is a guard, and one assigned to an `out` parameter of a Try method that returns `bool`. | | CMTK0002 | Error | A type implements `IValue`, `IValidatedValue<,,>` or `IClosedSet`, but the compilation doesn't reference `CodoMetis.TypeKit.Generators`. So nothing is generated for it: no field, no `Value`, no factory, or for a closed set no name and no `Match`. | -| CMTK0003 | Error | A `Result` or `Option` that a call returns, or a collection or a tuple of them that the statement made, dropped. It counts as dropped as a statement, awaited or not; when its `Task` is converted to a plain `Task`, also where a method group is the conversion; through `Wait`; in a loop that never reads it; and in a branch of a conditional, a `switch` expression or a `??` that picked the call. `_` says you mean the drop. | +| CMTK0003 | Error | A `Result` or `Option` that a call returns, or a collection or a tuple of them that the statement made, dropped. It counts as dropped as a statement, awaited or not; when its `Task` is converted to a plain `Task`, also where a method group is the conversion; through `Wait`; in a loop that never reads it; and in a branch of a conditional, a `switch` expression or a `??` that picked the call. Also an assertion of `CodoMetis.TypeKit.Testing` on a task, left without `await`. `_` says you mean the drop. | | CMTK0004 | Error | `Materialize`, which rebuilds a value object without its rules, called or referenced anywhere but the EF Core package. | | CMTK0005 | Warning | An array or span of a value object, `Option`, `Result` or `[RequireCustomInitialization]` struct created with a length, so every slot starts as `default`. A constant length of zero isn't reported. | | CMTK0006 | Warning | A field or auto-property of such a type, in a class, that no initializer, `required` or constructor sets. Also a `required` one that a `[SetsRequiredMembers]` constructor doesn't set. | @@ -182,6 +182,12 @@ call's: `await orders.CancelAsync(id).ConfigureAwait(false);` is reported, namin explicit cast, to `Task` or to the delegate type, says you mean the drop. A `ValueTask` has no conversion to `ValueTask`. +**Assertions on a task.** An assertion of `CodoMetis.TypeKit.Testing` on a `Task` or `ValueTask`, +such as `service.FindAsync(id).ShouldBeSome()`, fails the test only through the task it returns. +Left without `await`, the test passes whatever the task holds, and C# warns only in an `async` +method. CMTK0003 reports the line, naming the assertion. The rule knows these assertions by the +package's own assembly, so an extension of yours on a pending result is left alone. + **Collections.** A collection of results is dropped as silently as one result: ```csharp diff --git a/src/CodoMetis.TypeKit.Analyzers/TypeKitSymbols.cs b/src/CodoMetis.TypeKit.Analyzers/TypeKitSymbols.cs index 93f02155..652cc780 100644 --- a/src/CodoMetis.TypeKit.Analyzers/TypeKitSymbols.cs +++ b/src/CodoMetis.TypeKit.Analyzers/TypeKitSymbols.cs @@ -52,6 +52,12 @@ internal sealed class TypeKitSymbols /// private const string GeneratorsAssembly = "CodoMetis.TypeKit.Generators"; + /// + /// The assembly of the test assertions, whose assertions on a pending value fail a test only through + /// the task they return. This is the package's own identity, which a consumer cannot rename. + /// + private const string TestingAssembly = "CodoMetis.TypeKit.Testing"; + private const string Materialize = "Materialize"; private readonly ImmutableArray _valueMarkers; @@ -108,6 +114,10 @@ private TypeKitSymbols( _computeRestriction = ComputeRestriction; } + /// Whether is CodoMetis.TypeKit.Testing. + public static bool IsTestingAssembly(IAssemblySymbol? assembly) => + assembly is not null && string.Equals(assembly.Name, TestingAssembly, StringComparison.Ordinal); + /// Whether the compilation references CodoMetis.TypeKit.Generators. public bool ReferencesGenerators { get; } diff --git a/src/CodoMetis.TypeKit.Testing/FaultAssertions.cs b/src/CodoMetis.TypeKit.Testing/FaultAssertions.cs index 6b825b3d..f7b676f5 100644 --- a/src/CodoMetis.TypeKit.Testing/FaultAssertions.cs +++ b/src/CodoMetis.TypeKit.Testing/FaultAssertions.cs @@ -49,6 +49,76 @@ public static Fault ShouldHold<[Distinct] T1, [Distinct] T2, [Distinct] T3>( public static Fault ShouldHold<[Distinct] T1, [Distinct] T2, [Distinct] T3>(this Fault fault, T3 reason) where T1 : notnull where T2 : notnull where T3 : notnull => HeldFaults.Expect, T3>(reason, Uninitialized(fault), () => fault.Match(held => held, held => held, held => held)); + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task> ShouldHold<[Distinct] T1, [Distinct] T2>(this Task> fault, T1 reason) where T1 : notnull where T2 : notnull + { + return (await Outcomes.Pending(fault, nameof(fault)).ConfigureAwait(false)).ShouldHold(reason); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task> ShouldHold<[Distinct] T1, [Distinct] T2>(this ValueTask> fault, T1 reason) where T1 : notnull where T2 : notnull + { + return (await fault.ConfigureAwait(false)).ShouldHold(reason); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task> ShouldHold<[Distinct] T1, [Distinct] T2>(this Task> fault, T2 reason) where T1 : notnull where T2 : notnull + { + return (await Outcomes.Pending(fault, nameof(fault)).ConfigureAwait(false)).ShouldHold(reason); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task> ShouldHold<[Distinct] T1, [Distinct] T2>(this ValueTask> fault, T2 reason) where T1 : notnull where T2 : notnull + { + return (await fault.ConfigureAwait(false)).ShouldHold(reason); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task> ShouldHold<[Distinct] T1, [Distinct] T2, [Distinct] T3>(this Task> fault, T1 reason) where T1 : notnull where T2 : notnull where T3 : notnull + { + return (await Outcomes.Pending(fault, nameof(fault)).ConfigureAwait(false)).ShouldHold(reason); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task> ShouldHold<[Distinct] T1, [Distinct] T2, [Distinct] T3>(this ValueTask> fault, T1 reason) where T1 : notnull where T2 : notnull where T3 : notnull + { + return (await fault.ConfigureAwait(false)).ShouldHold(reason); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task> ShouldHold<[Distinct] T1, [Distinct] T2, [Distinct] T3>(this Task> fault, T2 reason) where T1 : notnull where T2 : notnull where T3 : notnull + { + return (await Outcomes.Pending(fault, nameof(fault)).ConfigureAwait(false)).ShouldHold(reason); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task> ShouldHold<[Distinct] T1, [Distinct] T2, [Distinct] T3>(this ValueTask> fault, T2 reason) where T1 : notnull where T2 : notnull where T3 : notnull + { + return (await fault.ConfigureAwait(false)).ShouldHold(reason); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task> ShouldHold<[Distinct] T1, [Distinct] T2, [Distinct] T3>(this Task> fault, T3 reason) where T1 : notnull where T2 : notnull where T3 : notnull + { + return (await Outcomes.Pending(fault, nameof(fault)).ConfigureAwait(false)).ShouldHold(reason); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task> ShouldHold<[Distinct] T1, [Distinct] T2, [Distinct] T3>(this ValueTask> fault, T3 reason) where T1 : notnull where T2 : notnull where T3 : notnull + { + return (await fault.ConfigureAwait(false)).ShouldHold(reason); + } + private static bool Uninitialized(TComposed fault) where TComposed : struct => EqualityComparer.Default.Equals(fault, default); } diff --git a/src/CodoMetis.TypeKit.Testing/OptionAssertions.cs b/src/CodoMetis.TypeKit.Testing/OptionAssertions.cs index 5eaa2d40..e7db1a8f 100644 --- a/src/CodoMetis.TypeKit.Testing/OptionAssertions.cs +++ b/src/CodoMetis.TypeKit.Testing/OptionAssertions.cs @@ -24,4 +24,32 @@ public static void ShouldBeNone(this Option option) where T : notnull if (option.IsSome()) throw new TypeKitAssertionException($"Expected None, but the {Outcomes.Name>()} holds a value (not printed)."); } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeSome(this Task> option) where T : notnull + { + return (await Outcomes.Pending(option, nameof(option)).ConfigureAwait(false)).ShouldBeSome(); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeSome(this ValueTask> option) where T : notnull + { + return (await option.ConfigureAwait(false)).ShouldBeSome(); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeNone(this Task> option) where T : notnull + { + (await Outcomes.Pending(option, nameof(option)).ConfigureAwait(false)).ShouldBeNone(); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeNone(this ValueTask> option) where T : notnull + { + (await option.ConfigureAwait(false)).ShouldBeNone(); + } } diff --git a/src/CodoMetis.TypeKit.Testing/Outcomes.cs b/src/CodoMetis.TypeKit.Testing/Outcomes.cs index 9bda0d7e..60fb8b24 100644 --- a/src/CodoMetis.TypeKit.Testing/Outcomes.cs +++ b/src/CodoMetis.TypeKit.Testing/Outcomes.cs @@ -16,6 +16,9 @@ public static void ThrowIfOtherError(TError actual, TError exp throw new TypeKitAssertionException($"Expected the error {expected}, but the {Name()} is the error {actual}."); } + /// The task an asynchronous assertion awaits, refused when null: awaited, it was a naming nothing. + public static Task Pending(Task? task, string paramName) => task ?? throw new ArgumentNullException(paramName, "The assertion was given no task to await."); + /// /// Result<Order, OrderFault>, as the type is written in C# from outside its containing /// types: Billing.Fault, Outer<Int32>.Inner. diff --git a/src/CodoMetis.TypeKit.Testing/PatchAssertions.cs b/src/CodoMetis.TypeKit.Testing/PatchAssertions.cs index 5a131c6c..769b45ad 100644 --- a/src/CodoMetis.TypeKit.Testing/PatchAssertions.cs +++ b/src/CodoMetis.TypeKit.Testing/PatchAssertions.cs @@ -34,6 +34,48 @@ public static void ShouldBeAbsent(this Patch patch) where T : notnull throw new TypeKitAssertionException($"Expected Absent, but the {Outcomes.Name>()} {Was(patch.State)}."); } + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeValue(this Task> patch) where T : notnull + { + return (await Outcomes.Pending(patch, nameof(patch)).ConfigureAwait(false)).ShouldBeValue(); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeValue(this ValueTask> patch) where T : notnull + { + return (await patch.ConfigureAwait(false)).ShouldBeValue(); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeNull(this Task> patch) where T : notnull + { + (await Outcomes.Pending(patch, nameof(patch)).ConfigureAwait(false)).ShouldBeNull(); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeNull(this ValueTask> patch) where T : notnull + { + (await patch.ConfigureAwait(false)).ShouldBeNull(); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeAbsent(this Task> patch) where T : notnull + { + (await Outcomes.Pending(patch, nameof(patch)).ConfigureAwait(false)).ShouldBeAbsent(); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeAbsent(this ValueTask> patch) where T : notnull + { + (await patch.ConfigureAwait(false)).ShouldBeAbsent(); + } + private static string Was(PatchState state) => state == PatchState.Value ? "holds a value (not printed)" : $"is {Describe(state)}"; private static string Describe(PatchState state) => state switch diff --git a/src/CodoMetis.TypeKit.Testing/README.md b/src/CodoMetis.TypeKit.Testing/README.md index 46558db9..4b7fb84b 100644 --- a/src/CodoMetis.TypeKit.Testing/README.md +++ b/src/CodoMetis.TypeKit.Testing/README.md @@ -43,6 +43,15 @@ Fault stale = refusal.ShouldHold(EditFault.Stale); // this fault, o string? detail = stale.Detail; // for the test's own assertions ``` +Every assertion also works on a `Task` or a `ValueTask` of what it checks. Put `await` in front, and +it waits for the task, then checks what the task returned: + +```csharp +Email email = await signups.RegisterAsync("ops@example.com").ShouldBeSuccess(); // awaited, then asserted +await signups.RegisterAsync("nobody").ShouldBeError(EmailFault.NoAt); +await signups.FindAsync("ops@example.com").ShouldBeNone(); // a ValueTask too +``` + - **The content comes back.** `ShouldBeSome()`, `ShouldBeSuccess()`, `ShouldBeError()` and `ShouldBeValue()` return the value or the error. Compare it with whatever assertions your tests already use: xUnit's `Assert`, Shouldly, or another. @@ -54,3 +63,8 @@ string? detail = stale.Detail; // for the test' `Option`, `Result` and `Patch` themselves never do. - **An uninitialized result fails every assertion.** The message says it is a default instance, neither a success nor an error. +- **Await an assertion on a task.** It fails your test only through the task it returns, so without + `await` the test passes whatever the task holds. The analyzer reports that line (CMTK0003), in a + test method that isn't `async` too, where C# itself says nothing. +- **A task that throws fails your test with its own exception**, as it was thrown, and not as an + assertion failure. The assertion never sees an outcome. diff --git a/src/CodoMetis.TypeKit.Testing/ResultAssertions.cs b/src/CodoMetis.TypeKit.Testing/ResultAssertions.cs index d8e29452..b46572b3 100644 --- a/src/CodoMetis.TypeKit.Testing/ResultAssertions.cs +++ b/src/CodoMetis.TypeKit.Testing/ResultAssertions.cs @@ -83,4 +83,100 @@ public static TError ShouldBeError(this Result result) where TEr /// The result is a success, another error, or uninitialized. public static void ShouldBeError(this Result result, TError expected) where TError : notnull => Outcomes.ThrowIfOtherError, TError>(result.ShouldBeError(), expected); + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeSuccess(this Task> result) + where T : notnull + where TError : notnull + { + return (await Outcomes.Pending(result, nameof(result)).ConfigureAwait(false)).ShouldBeSuccess(); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeSuccess(this ValueTask> result) + where T : notnull + where TError : notnull + { + return (await result.ConfigureAwait(false)).ShouldBeSuccess(); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeError(this Task> result) + where T : notnull + where TError : notnull + { + return (await Outcomes.Pending(result, nameof(result)).ConfigureAwait(false)).ShouldBeError(); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeError(this ValueTask> result) + where T : notnull + where TError : notnull + { + return (await result.ConfigureAwait(false)).ShouldBeError(); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeError(this Task> result, TError expected) + where T : notnull + where TError : notnull + { + (await Outcomes.Pending(result, nameof(result)).ConfigureAwait(false)).ShouldBeError(expected); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeError(this ValueTask> result, TError expected) + where T : notnull + where TError : notnull + { + (await result.ConfigureAwait(false)).ShouldBeError(expected); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeSuccess(this Task> result) where TError : notnull + { + (await Outcomes.Pending(result, nameof(result)).ConfigureAwait(false)).ShouldBeSuccess(); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeSuccess(this ValueTask> result) where TError : notnull + { + (await result.ConfigureAwait(false)).ShouldBeSuccess(); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeError(this Task> result) where TError : notnull + { + return (await Outcomes.Pending(result, nameof(result)).ConfigureAwait(false)).ShouldBeError(); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeError(this ValueTask> result) where TError : notnull + { + return (await result.ConfigureAwait(false)).ShouldBeError(); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeError(this Task> result, TError expected) where TError : notnull + { + (await Outcomes.Pending(result, nameof(result)).ConfigureAwait(false)).ShouldBeError(expected); + } + + /// + /// Awaits first: an exception the task ends with propagates as it is. + public static async Task ShouldBeError(this ValueTask> result, TError expected) where TError : notnull + { + (await result.ConfigureAwait(false)).ShouldBeError(expected); + } } diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/AnalyzerTest.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/AnalyzerTest.cs index 712ba855..061fe502 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/AnalyzerTest.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/AnalyzerTest.cs @@ -57,6 +57,13 @@ internal static class RealEntityFrameworkCore ]; } +/// The real test assertions, whose assertions on a pending value CMTK0003 knows by the assembly's identity. +internal static class RealTesting +{ + public static MetadataReference Reference { get; } = + MetadataReference.CreateFromFile(typeof(global::CodoMetis.TypeKit.Testing.TypeKitAssertionException).Assembly.Location); +} + internal static class RealTypeKit { public static Assembly Assembly { get; } = typeof(Option<>).Assembly; diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/CodoMetis.TypeKit.Analyzers.Tests.csproj b/test/CodoMetis.TypeKit.Analyzers.Tests/CodoMetis.TypeKit.Analyzers.Tests.csproj index c6103ac4..a6513c17 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/CodoMetis.TypeKit.Analyzers.Tests.csproj +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/CodoMetis.TypeKit.Analyzers.Tests.csproj @@ -12,6 +12,7 @@ + diff --git a/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs b/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs index a6961367..a5d51f73 100644 --- a/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs +++ b/test/CodoMetis.TypeKit.Analyzers.Tests/IgnoredOutcomeAnalyzerTests.cs @@ -94,6 +94,103 @@ private static AnalyzerTest TestWithFile(string file, pa return test; } + /// The subjects, with the real test assertions referenced and imported. + private static AnalyzerTest TestWithAssertions(string code, params DiagnosticResult[] expected) + { + var test = Test("", expected); + test.TestState.AdditionalReferences.Add(RealTesting.Reference); + test.TestState.Sources.Add(("Caller.cs", "using System;\nusing System.Threading.Tasks;\nusing CodoMetis.TypeKit;\nusing CodoMetis.TypeKit.Testing;\n\n" + code)); + return test; + } + + /// + /// An assertion on a pending value fails the test only through the task it returns: dropped + /// unawaited, in a test method that is not async (where C# warns of nothing) or in one that is, + /// it passes whatever the value is. + /// + [Fact] + public Task A_dropped_assertion_on_a_pending_value_reports() => + TestWithAssertions( + """ + public static class Caller + { + public static void Run(Service service) + { + {|#0:service.LoadAsync().ShouldBeSuccess()|}; + {|#1:service.FindAsync().ShouldBeNone()|}; + {|#2:service.SaveAsync().ShouldBeError(Fault.Refused)|}; + {|#3:service.LoadAsync().ShouldBeSuccess().ConfigureAwait(false)|}; + Action check = () => {|#4:service.FindAsync().ShouldBeSome()|}; + } + + public static async Task RunAsync(Service service) + { + #pragma warning disable CS4014 + {|#5:service.LoadAsync().ShouldBeError()|}; + #pragma warning restore CS4014 + await Task.Yield(); + } + } + """, + new DiagnosticResult("CMTK0003", DiagnosticSeverity.Error) + .WithLocation(0) + .WithMessage("The Task that 'ShouldBeSuccess' returns is ignored, so its outcome is never seen. Handle it, or discard it with '_ =' if nothing depends on it."), + Cmtk0003(1, "Task", "ShouldBeNone"), + Cmtk0003(2, "Task", "ShouldBeError"), + Cmtk0003(3, "Task", "ShouldBeSuccess"), + Cmtk0003(4, "Task", "ShouldBeSome"), + Cmtk0003(5, "Task", "ShouldBeError")) + .RunAsync(TestContext.Current.CancellationToken); + + [Fact] + public Task An_awaited_held_or_discarded_assertion_is_silent() => + TestWithAssertions( + """ + public static class Caller + { + public static async Task RunAsync(Service service) + { + await service.LoadAsync().ShouldBeSuccess(); + await service.FindAsync().ShouldBeNone().ConfigureAwait(false); + var pending = service.LoadAsync().ShouldBeSuccess(); + await pending; + _ = service.SaveAsync().ShouldBeSuccess(); + service.Load().ShouldBeSuccess(); + } + + public static Task Returned(Service service) => service.FindAsync().ShouldBeNone(); + + public static Func Deferred(Service service) => () => service.FindAsync().ShouldBeNone(); + } + """) + .RunAsync(TestContext.Current.CancellationToken); + + /// + /// Only the package's own assertions are reported: the same shape from any other assembly, a + /// consumer's own extension on a pending result among them, built before 2.3 and builds still. + /// + [Fact] + public Task A_task_of_another_assembly_s_extension_on_a_pending_value_is_silent() => + TestWithAssertions( + """ + public static class Logging + { + public static Task LogAsync(this Task> pending) where T : notnull => pending; + + public static Task ShouldBeSuccess(this Task> pending) where T : notnull => pending; + } + + public static class Caller + { + public static void Run(Service service) + { + service.LoadAsync().LogAsync(); + Logging.ShouldBeSuccess(service.FindAsync()); + } + } + """) + .RunAsync(TestContext.Current.CancellationToken); + [Fact] public void The_descriptor_is_the_published_contract() { diff --git a/test/CodoMetis.TypeKit.Samples/Testing/TestingSamples.cs b/test/CodoMetis.TypeKit.Samples/Testing/TestingSamples.cs index 974c5016..c4e84f1b 100644 --- a/test/CodoMetis.TypeKit.Samples/Testing/TestingSamples.cs +++ b/test/CodoMetis.TypeKit.Samples/Testing/TestingSamples.cs @@ -22,6 +22,18 @@ public Result> Rename(int id, int version) => Fault.Of(EditFault.Stale, $"Order {id} is at version {version + 1}, not {version}."); } +/// A service answering through tasks, for the asynchronous block. +public sealed class Signups +{ + public async Task> RegisterAsync(string address) + { + await Task.Yield(); + return Email.Create(address); + } + + public ValueTask> FindAsync(string address) => new(Option.None()); +} + public sealed partial class TestingSamples { private const string Readme = "src/CodoMetis.TypeKit.Testing/README.md"; @@ -74,6 +86,21 @@ public void A_composed_fault_hands_back_the_fault_it_holds() Should.Throw(() => refusal.ShouldHold(OrderFault.NotFound)); } + /// The README's asynchronous block, run: each line awaits and passes, or this test fails with its assertion. + [Fact] + public async Task The_assertions_on_a_task_pass_as_the_README_shows() + { + var signups = new Signups(); + + // sample: CodoMetis.TypeKit.Testing/async + Email email = await signups.RegisterAsync("ops@example.com").ShouldBeSuccess(); // awaited, then asserted + await signups.RegisterAsync("nobody").ShouldBeError(EmailFault.NoAt); + await signups.FindAsync("ops@example.com").ShouldBeNone(); // a ValueTask too + // end sample + + email.Value.ShouldBe("ops@example.com"); + } + /// The failure message the README quotes, read from the README, is the one the assertion throws. [Fact] public void A_failure_says_what_the_README_quotes() diff --git a/test/CodoMetis.TypeKit.Testing.Tests/AsyncAssertionTests.cs b/test/CodoMetis.TypeKit.Testing.Tests/AsyncAssertionTests.cs new file mode 100644 index 00000000..d3f3ddef --- /dev/null +++ b/test/CodoMetis.TypeKit.Testing.Tests/AsyncAssertionTests.cs @@ -0,0 +1,244 @@ +using System.Reflection; + +namespace CodoMetis.TypeKit.Testing.Tests; + +/// +/// Every assertion has a form on a Task and on a ValueTask of what it asserts on, which +/// awaits it and answers as the synchronous form does: the same content back, the same message on a +/// failure. A task that faults is the test's own failure, and its exception propagates as it is. +/// +public sealed class AsyncAssertionTests +{ + public enum Fault { NotFound, AlreadyShipped } + + public enum Other { Conflict } + + public enum Third { Gone } + + private const string Secret = "s3cr3t-value"; + + private static readonly Result Success = Secret; + private static readonly Result Error = Result.Error(Fault.NotFound); + private static readonly Result Done = Result.Success(); + private static readonly Result Refused = Result.Error(Fault.AlreadyShipped); + private static readonly Fault Pair = Fault.Of(Fault.NotFound); + private static readonly Fault Triple = Fault.Of(Third.Gone); + + private static async Task Later(T value) + { + await Task.Yield(); + return value; + } + + private static async ValueTask LaterValue(T value) + { + await Task.Yield(); + return value; + } + + /// + /// Each synchronous assertion, keyed by its signature, with inputs it passes and fails on: the + /// synchronous call, the Task form's and the ValueTask form's over the same input. + /// + private static readonly Dictionary Sync, Func> OnTask, Func> OnValueTask)[]> Cases = new() + { + ["ShouldBeSome(Option)"] = + [ + (() => Option.Some(Secret).ShouldBeSome(), async () => await Later(Option.Some(Secret)).ShouldBeSome(), async () => await LaterValue(Option.Some(Secret)).ShouldBeSome()), + (() => Option.None().ShouldBeSome(), async () => await Later(Option.None()).ShouldBeSome(), async () => await LaterValue(Option.None()).ShouldBeSome()), + ], + ["ShouldBeNone(Option)"] = + [ + (Void(() => Option.None().ShouldBeNone()), Void(() => Later(Option.None()).ShouldBeNone()), Void(() => LaterValue(Option.None()).ShouldBeNone())), + (Void(() => Option.Some(Secret).ShouldBeNone()), Void(() => Later(Option.Some(Secret)).ShouldBeNone()), Void(() => LaterValue(Option.Some(Secret)).ShouldBeNone())), + ], + ["ShouldBeSuccess(Result)"] = + [ + (() => Success.ShouldBeSuccess(), async () => await Later(Success).ShouldBeSuccess(), async () => await LaterValue(Success).ShouldBeSuccess()), + (() => Error.ShouldBeSuccess(), async () => await Later(Error).ShouldBeSuccess(), async () => await LaterValue(Error).ShouldBeSuccess()), + (() => default(Result).ShouldBeSuccess(), async () => await Later(default(Result)).ShouldBeSuccess(), async () => await LaterValue(default(Result)).ShouldBeSuccess()), + ], + ["ShouldBeError(Result)"] = + [ + (() => Error.ShouldBeError(), async () => await Later(Error).ShouldBeError(), async () => await LaterValue(Error).ShouldBeError()), + (() => Success.ShouldBeError(), async () => await Later(Success).ShouldBeError(), async () => await LaterValue(Success).ShouldBeError()), + ], + ["ShouldBeError(Result, TError)"] = + [ + (Void(() => Error.ShouldBeError(Fault.NotFound)), Void(() => Later(Error).ShouldBeError(Fault.NotFound)), Void(() => LaterValue(Error).ShouldBeError(Fault.NotFound))), + (Void(() => Error.ShouldBeError(Fault.AlreadyShipped)), Void(() => Later(Error).ShouldBeError(Fault.AlreadyShipped)), Void(() => LaterValue(Error).ShouldBeError(Fault.AlreadyShipped))), + ], + ["ShouldBeSuccess(Result)"] = + [ + (Void(() => Done.ShouldBeSuccess()), Void(() => Later(Done).ShouldBeSuccess()), Void(() => LaterValue(Done).ShouldBeSuccess())), + (Void(() => Refused.ShouldBeSuccess()), Void(() => Later(Refused).ShouldBeSuccess()), Void(() => LaterValue(Refused).ShouldBeSuccess())), + ], + ["ShouldBeError(Result)"] = + [ + (() => Refused.ShouldBeError(), async () => await Later(Refused).ShouldBeError(), async () => await LaterValue(Refused).ShouldBeError()), + (() => Done.ShouldBeError(), async () => await Later(Done).ShouldBeError(), async () => await LaterValue(Done).ShouldBeError()), + ], + ["ShouldBeError(Result, TError)"] = + [ + (Void(() => Refused.ShouldBeError(Fault.AlreadyShipped)), Void(() => Later(Refused).ShouldBeError(Fault.AlreadyShipped)), Void(() => LaterValue(Refused).ShouldBeError(Fault.AlreadyShipped))), + (Void(() => Refused.ShouldBeError(Fault.NotFound)), Void(() => Later(Refused).ShouldBeError(Fault.NotFound)), Void(() => LaterValue(Refused).ShouldBeError(Fault.NotFound))), + ], + ["ShouldBeValue(Patch)"] = + [ + (() => Patch.Value(Secret).ShouldBeValue(), async () => await Later(Patch.Value(Secret)).ShouldBeValue(), async () => await LaterValue(Patch.Value(Secret)).ShouldBeValue()), + (() => Patch.Null().ShouldBeValue(), async () => await Later(Patch.Null()).ShouldBeValue(), async () => await LaterValue(Patch.Null()).ShouldBeValue()), + ], + ["ShouldBeNull(Patch)"] = + [ + (Void(() => Patch.Null().ShouldBeNull()), Void(() => Later(Patch.Null()).ShouldBeNull()), Void(() => LaterValue(Patch.Null()).ShouldBeNull())), + (Void(() => Patch.Value(Secret).ShouldBeNull()), Void(() => Later(Patch.Value(Secret)).ShouldBeNull()), Void(() => LaterValue(Patch.Value(Secret)).ShouldBeNull())), + ], + ["ShouldBeAbsent(Patch)"] = + [ + (Void(() => Patch.Absent().ShouldBeAbsent()), Void(() => Later(Patch.Absent()).ShouldBeAbsent()), Void(() => LaterValue(Patch.Absent()).ShouldBeAbsent())), + (Void(() => Patch.Null().ShouldBeAbsent()), Void(() => Later(Patch.Null()).ShouldBeAbsent()), Void(() => LaterValue(Patch.Null()).ShouldBeAbsent())), + ], + ["ShouldHold(Fault, T1)"] = + [ + (() => Pair.ShouldHold(Fault.NotFound), async () => await Later(Pair).ShouldHold(Fault.NotFound), async () => await LaterValue(Pair).ShouldHold(Fault.NotFound)), + (() => Pair.ShouldHold(Fault.AlreadyShipped), async () => await Later(Pair).ShouldHold(Fault.AlreadyShipped), async () => await LaterValue(Pair).ShouldHold(Fault.AlreadyShipped)), + ], + ["ShouldHold(Fault, T2)"] = + [ + (() => Pair.ShouldHold(Other.Conflict), async () => await Later(Pair).ShouldHold(Other.Conflict), async () => await LaterValue(Pair).ShouldHold(Other.Conflict)), + ], + ["ShouldHold(Fault, T1)"] = + [ + (() => Triple.ShouldHold(Fault.NotFound), async () => await Later(Triple).ShouldHold(Fault.NotFound), async () => await LaterValue(Triple).ShouldHold(Fault.NotFound)), + ], + ["ShouldHold(Fault, T2)"] = + [ + (() => Triple.ShouldHold(Other.Conflict), async () => await Later(Triple).ShouldHold(Other.Conflict), async () => await LaterValue(Triple).ShouldHold(Other.Conflict)), + ], + ["ShouldHold(Fault, T3)"] = + [ + (() => Triple.ShouldHold(Third.Gone), async () => await Later(Triple).ShouldHold(Third.Gone), async () => await LaterValue(Triple).ShouldHold(Third.Gone)), + ], + }; + + public static TheoryData CaseNames => [.. Cases.Keys]; + + [Theory] + [MemberData(nameof(CaseNames))] + public async Task Each_form_answers_as_the_synchronous_assertion_does(string assertion) + { + foreach (var (sync, onTask, onValueTask) in Cases[assertion]) + { + var expected = Outcome(sync); + + (await OutcomeAsync(onTask)).ShouldBe(expected, $"{assertion} on a Task"); + (await OutcomeAsync(onValueTask)).ShouldBe(expected, $"{assertion} on a ValueTask"); + } + } + + /// A task that faults failed the code under test, not the assertion: its exception comes out as it was thrown. + [Fact] + public async Task A_faulted_task_s_exception_propagates_as_it_is() + { + var thrown = new InvalidOperationException("the store is down"); + + async Task> Faults() { await Task.Yield(); throw thrown; } + async ValueTask> FaultsValue() { await Task.Yield(); throw thrown; } + + (await Should.ThrowAsync(() => Faults().ShouldBeSuccess())).ShouldBeSameAs(thrown); + (await Should.ThrowAsync(() => Faults().ShouldBeError(Fault.NotFound))).ShouldBeSameAs(thrown); + (await Should.ThrowAsync(async () => await FaultsValue().ShouldBeSome())).ShouldBeSameAs(thrown); + + var cancelled = Task.FromCanceled>(new CancellationToken(canceled: true)); + await Should.ThrowAsync(() => cancelled.ShouldBeNone()); + } + + [Fact] + public async Task A_null_task_is_refused_by_name() + { + (await Should.ThrowAsync(() => ((Task>)null!).ShouldBeSome())).ParamName.ShouldBe("option"); + (await Should.ThrowAsync(() => ((Task>)null!).ShouldBeSuccess())).ParamName.ShouldBe("result"); + (await Should.ThrowAsync(() => ((Task>)null!).ShouldBeAbsent())).ParamName.ShouldBe("patch"); + (await Should.ThrowAsync(() => ((Task>)null!).ShouldHold(Other.Conflict))).ParamName.ShouldBe("fault"); + } + + /// + /// Every public assertion on a value has a case here, a form on a Task and one on a + /// ValueTask of the value, with its other parameters, and nothing else is public. + /// + [Fact] + public void Every_assertion_has_a_Task_and_a_ValueTask_form() + { + var methods = (from type in typeof(TypeKitAssertionException).Assembly.GetExportedTypes() + from method in type.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + select method).ToList(); + + var synchronous = methods.Where(method => Pending(method.GetParameters()[0].ParameterType) is null).ToList(); + var keys = synchronous.Select(Key).ToList(); + + keys.Count.ShouldBe(16); + keys.Except(Cases.Keys).ShouldBeEmpty("These assertions have no case here."); + Cases.Keys.Except(keys).ShouldBeEmpty("These cases name no assertion."); + + foreach (var wrapper in new[] { typeof(Task<>), typeof(ValueTask<>) }) + { + var forms = methods.Where(method => Pending(method.GetParameters()[0].ParameterType) == wrapper).ToList(); + forms.Select(form => Key(form)).Order(StringComparer.Ordinal).ShouldBe(keys.Order(StringComparer.Ordinal), $"each assertion has one form on a {wrapper.Name}"); + + foreach (var form in forms) + { + var twin = synchronous.Single(method => Key(method) == Key(form)); + form.DeclaringType.ShouldBe(twin.DeclaringType, $"{Key(form)} sits beside its synchronous form"); + Describe(form.ReturnType).ShouldBe(twin.ReturnType == typeof(void) ? "Task" : $"Task<{Describe(twin.ReturnType)}>", Key(form)); + } + } + } + + private static Type? Pending(Type type) => + type.IsGenericType && (type.GetGenericTypeDefinition() == typeof(Task<>) || type.GetGenericTypeDefinition() == typeof(ValueTask<>)) + ? type.GetGenericTypeDefinition() + : null; + + private static string Key(MethodInfo method) => + $"{method.Name}({string.Join(", ", method.GetParameters().Select(p => Describe(Pending(p.ParameterType) is null || p.Position > 0 ? p.ParameterType : p.ParameterType.GetGenericArguments()[0])))})"; + + private static string Describe(Type type) => + type.IsGenericType ? $"{type.Name[..type.Name.IndexOf('`')]}<{string.Join(", ", type.GetGenericArguments().Select(Describe))}>" : type.Name; + + private static string Outcome(Func call) + { + try + { + return $"passes: {Show(call())}"; + } + catch (TypeKitAssertionException failure) + { + return $"fails: {failure.Message}"; + } + } + + private static async Task OutcomeAsync(Func> call) + { + try + { + return $"passes: {Show(await call())}"; + } + catch (TypeKitAssertionException failure) + { + return $"fails: {failure.Message}"; + } + } + + private static string Show(object? value) => value switch + { + null => "nothing", + Fault held => $"{held.Reason} {held.Detail}", + Fault held => $"{held.Reason} {held.Detail}", + Fault held => $"{held.Reason} {held.Detail}", + _ => value.ToString()!, + }; + + private static Func Void(Action call) => () => { call(); return null; }; + + private static Func> Void(Func call) => async () => { await call(); return null; }; +} diff --git a/tools/probe/corpus/seeds/C20-testing-shouldhold-whichever-of-its-types.patch b/tools/probe/corpus/seeds/C20-testing-shouldhold-whichever-of-its-types.patch index 9be5f520..ce91c328 100644 --- a/tools/probe/corpus/seeds/C20-testing-shouldhold-whichever-of-its-types.patch +++ b/tools/probe/corpus/seeds/C20-testing-shouldhold-whichever-of-its-types.patch @@ -1,5 +1,5 @@ diff --git a/src/CodoMetis.TypeKit.Testing/FaultAssertions.cs b/src/CodoMetis.TypeKit.Testing/FaultAssertions.cs -index 6b825b3..9839131 100644 +index f7b676f..6f84db3 100644 --- a/src/CodoMetis.TypeKit.Testing/FaultAssertions.cs +++ b/src/CodoMetis.TypeKit.Testing/FaultAssertions.cs @@ -23,7 +23,7 @@ public static class FaultAssertions @@ -18,5 +18,5 @@ index 6b825b3..9839131 100644 - HeldFaults.Expect, T3>(reason, Uninitialized(fault), () => fault.Match(held => held, held => held, held => held)); + HeldFaults.Expect, T3>(reason, Uninitialized(fault), () => fault.Match(held => held, held => held, held => held.Reason)); - private static bool Uninitialized(TComposed fault) where TComposed : struct => - EqualityComparer.Default.Equals(fault, default); + /// + /// Awaits first: an exception the task ends with propagates as it is.