Skip to content

Support asserting on delegates that return a value task - #3301

Merged
dennisdoomen merged 1 commit into
mainfrom
valuetask-should-support
Aug 16, 2026
Merged

dennisdoomen merged 1 commit into
mainfrom
valuetask-should-support

Conversation

@dennisdoomen

@dennisdoomen dennisdoomen commented Aug 10, 2026

Copy link
Copy Markdown
Member

Summary

Asserting on a delegate that returns a value task previously meant going through the Awaiting adapter or calling .AsTask() by hand, purely to reach the assertions that task-returning delegates already get directly. This gives value tasks the same first-class treatment, so ThrowAsync, NotThrowAsync, CompleteWithinAsync and friends work straight off a Func<ValueTask> or Func<ValueTask<T>>.

Closes #3269.

Reviewer note: this is a source-breaking change

Issue #3269 states that these delegate shapes "currently do not resolve to any existing Should() overload, so this is purely additive". That is not correct, and I want to flag it clearly rather than let it slip through.

AssertionExtensions already has a catch-all overload:

public static FunctionAssertions<T> Should<T>(this Func<T> func)

A Func<ValueTask> binds to it with T = ValueTask, and a Func<ValueTask<int>> binds with T = ValueTask<int>. So today this compiles:

Func<ValueTask> act = () => DoSomethingAsync();

act.Should().NotThrow();   // binds to FunctionAssertions<ValueTask>

It compiles, but it does not do what it looks like it does. FunctionAssertions<T> invokes the delegate and treats the returned value task as an ordinary return value. It never awaits it, so any exception thrown after the first suspension point goes unobserved and the assertion passes regardless. In other words, the existing binding is close to useless — but it is there, and people may have written it.

What changes

C# prefers a non-generic applicable overload over a generic one, so the new Should(this Func<ValueTask>) now wins. Two consequences:

1. Compile errors (CS1061). The synchronous members disappear from the returned type:

Was available Replacement
Throw<TException>() ThrowAsync<TException>()
ThrowExactly<TException>() ThrowExactlyAsync<TException>()
NotThrow() NotThrowAsync()
NotThrowAfter(...) NotThrowAfterAsync(...)

Anyone hitting this gets a loud compiler error and a one-line fix that also makes their test correct for the first time, so I think the break is worth taking.

2. A silent behaviour change on Subject. Members shared by both types — Subject, BeSameAs, Match, Satisfy — still compile but can now behave differently, because Subject no longer returns the delegate you passed in:

Func<ValueTask> act = () => DoSomethingAsync();

act.Should().Subject.Should().BeSameAs(act);   // passed before, fails now

This one I could not avoid. The asynchronous assertions derive from AsyncFunctionAssertions<TTask, TAssertions> where TTask : Task, so Subject is a Func<TTask> and a Func<ValueTask> can never be it. Preserving the original identity would need a whole new assertions type duplicating the existing async logic, which the issue explicitly rules out in favour of reusing the existing classes. Instead the adapter is documented in <remarks> on both overloads and pinned down by specs, so the behaviour is intentional rather than incidental.

Note this is source-breaking only. No existing type, member or signature is removed, so already-compiled assemblies keep working; only recompilation is affected.

How I verified it

I built origin/main in Release, compiled a scratch console app against the resulting FluentAssertions.dll, then swapped the reference to this branch's build and recompiled the same call sites. act.Should().NotThrow() compiles against main and fails with CS1061 against this branch. Reflection over FunctionAssertions<ValueTask> versus NonGenericAsyncFunctionAssertions produced the member table above.

What I would like you to decide

The api-approved label on #3269 was granted on the assumption that the change is additive. Given it is not, please confirm you are happy to take the break in the next release — the signatures themselves are unchanged from what was approved.

Design notes

Value tasks are common in modern, performance-sensitive code, but there was no direct entry point for them, which made the assertions harder to discover and inconsistent with their task-based counterparts.

Two new entry points accept a value-task-returning delegate and reuse the existing asynchronous assertions, so behaviour, chaining and failure messages stay identical to the task-based ones. The returned value task is converted exactly once per invocation, which respects the rule that a value task must not be awaited more than once. A null delegate is still reported as <null> rather than throwing.

Verification

  • 15 new specs cover throwing, not throwing, completing within a time limit, generic results, null delegates, the delegate being invoked exactly once, and the adapted subject.
  • FluentAssertions.Specs (6036 tests on net8.0/net6.0, 111 on net47), FluentAssertions.Equivalency.Specs and the API approval tests all pass.
  • The release notes record the break under "Breaking Changes (for users)".
  • Rebased onto the latest upstream/main; the only conflict was in the release notes, where this PR's "What's new" entry collided with a newly landed entry for HaveLineCount/ContainLine (Add HaveLineCount()/NotHaveLineCount() and ContainLine()/NotContainLine() to StringAssertions #3297) — resolved by keeping both.

@dennisdoomen
dennisdoomen marked this pull request as draft August 10, 2026 13:01
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

Test Results

    37 files  ±  0      37 suites  ±0   2m 41s ⏱️ -11s
 6 475 tests + 17   6 474 ✅ + 17  1 💤 ±0  0 ❌ ±0 
40 196 runs  +102  40 190 ✅ +102  6 💤 ±0  0 ❌ ±0 

Results for commit 9cc9c3a. ± Comparison against base commit 3ca926b.

This pull request removes 10 and adds 25 tests. Note that renamed tests count towards both.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HaveLength ‑ When_a_throwing_stream_should_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HaveLength ‑ When_a_throwing_stream_should_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HavePosition ‑ When_a_throwing_stream_should_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+HavePosition ‑ When_a_throwing_stream_should_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHaveLength ‑ When_a_throwing_stream_should_not_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHaveLength ‑ When_a_throwing_stream_should_not_have_a_length_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHavePosition ‑ When_a_throwing_stream_should_not_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
FluentAssertions.Specs.Streams.StreamAssertionSpecs+NotHavePosition ‑ When_a_throwing_stream_should_not_have_a_position_it_should_fail(exception: System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'GetLengthExceptionMessage'.)
Object name: 'GetPositionExceptionMessage'.)
FluentAssertions.Specs.AssertionExtensionsSpecs ‑ Should_methods_returning_reference_or_nullable_type_assertions_are_annotated_with_not_null_attribute(method: FluentAssertions.Specialized.GenericAsyncFunctionAssertions`1[T] Should[T](System.Func`1[System.Threading.Tasks.ValueTask`1[T]]))
FluentAssertions.Specs.AssertionExtensionsSpecs ‑ Should_methods_returning_reference_or_nullable_type_assertions_are_annotated_with_not_null_attribute(method: FluentAssertions.Specialized.NonGenericAsyncFunctionAssertions Should(System.Func`1[System.Threading.Tasks.ValueTask]))
FluentAssertions.Specs.Specialized.ValueTaskAssertionSpecs+CompleteWithinAsync ‑ Fails_for_a_null_delegate
FluentAssertions.Specs.Specialized.ValueTaskAssertionSpecs+CompleteWithinAsync ‑ Succeeds_for_a_generic_value_task_that_completes_in_time
FluentAssertions.Specs.Specialized.ValueTaskAssertionSpecs+CompleteWithinAsync ‑ Succeeds_for_a_value_task_that_completes_in_time
FluentAssertions.Specs.Specialized.ValueTaskAssertionSpecs+CompleteWithinAsync ‑ The_value_task_must_complete_within_the_time_limit
FluentAssertions.Specs.Specialized.ValueTaskAssertionSpecs+NotThrowAsync ‑ Fails_for_a_null_generic_delegate
FluentAssertions.Specs.Specialized.ValueTaskAssertionSpecs+NotThrowAsync ‑ Succeeds_for_a_generic_value_task_that_does_not_throw
FluentAssertions.Specs.Specialized.ValueTaskAssertionSpecs+NotThrowAsync ‑ Succeeds_for_a_value_task_that_does_not_throw
FluentAssertions.Specs.Specialized.ValueTaskAssertionSpecs+NotThrowAsync ‑ The_value_task_is_invoked_only_once
…

♻️ This comment has been updated with latest results.

@coveralls

coveralls commented Aug 10, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 31947239412

Coverage increased (+0.007%) to 97.197%

Details

  • Coverage increased (+0.007%) from the base build.
  • Patch coverage: 4 of 4 lines across 1 file are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 13513
Covered Lines: 13288
Line Coverage: 98.33%
Relevant Branches: 4394
Covered Branches: 4117
Branch Coverage: 93.7%
Branches in Coverage %: Yes
Coverage Strength: 68125.61 hits per line

💛 - Coveralls

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

Qodana for .NET

It seems all right 👌

No new problems were found according to the checks applied

💡 Qodana analysis was run in the pull request mode: only the changed files were checked
☁️ View the detailed Qodana report

Contact Qodana team

Contact us at qodana-support@jetbrains.com

@dennisdoomen
dennisdoomen force-pushed the valuetask-should-support branch from f50cf7f to 8c7b5d7 Compare August 15, 2026 09:23
@dennisdoomen
dennisdoomen force-pushed the valuetask-should-support branch from 8c7b5d7 to 24f579a Compare August 15, 2026 09:25
@dennisdoomen
dennisdoomen force-pushed the valuetask-should-support branch from 24f579a to cfc406f Compare August 15, 2026 09:26
@dennisdoomen
dennisdoomen force-pushed the valuetask-should-support branch from cfc406f to 1f9e8ab Compare August 15, 2026 09:33
@dennisdoomen
dennisdoomen force-pushed the valuetask-should-support branch from 1f9e8ab to f6d8d9d Compare August 15, 2026 09:38
@dennisdoomen
dennisdoomen force-pushed the valuetask-should-support branch from f6d8d9d to bc49038 Compare August 15, 2026 09:42
@dennisdoomen
dennisdoomen marked this pull request as ready for review August 15, 2026 09:42
@dennisdoomen
dennisdoomen requested a review from jnyrup August 15, 2026 09:57
@dennisdoomen dennisdoomen added this to the 8.11 milestone Aug 15, 2026
Comment thread docs/_pages/releases.md Outdated
Comment thread docs/_pages/releases.md Outdated
Comment thread docs/_pages/releases.md Outdated
Comment thread docs/_pages/releases.md Outdated
Comment thread docs/_pages/releases.md Outdated
Func<ValueTask> and Func<ValueTask<T>> now get their own Should()
overloads, so you can call ThrowAsync, ThrowExactlyAsync, NotThrowAsync
and NotThrowAfterAsync directly on a value-task-returning delegate
instead of converting it to a Task by hand first.

This is a breaking change. Those delegate types used to bind to the
synchronous Should<T>(Func<T>) overload, which treated the value task
as an ordinary return value. Code that calls Throw, ThrowExactly,
NotThrow or NotThrowAfter on a ValueTask-returning delegate must switch
to the *Async equivalents. Subject now exposes a task-based adapter
instead of the original delegate.

Also updates the .NET 6 API approval baseline, which was missing the
JSON serialization assertion members added in an earlier commit.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This was referenced Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Direct Should() support for Func<ValueTask> / Func<ValueTask<T>>

3 participants