Skip to content

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

Open
dennisdoomen wants to merge 1 commit into
mainfrom
valuetask-should-support
Open

Support asserting on delegates that return a value task#3301
dennisdoomen wants to merge 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

0 files   -     37  0 suites   - 37   0s ⏱️ - 2m 25s
0 tests  -  6 458  0 ✅  -  6 457  0 💤  - 1  0 ❌ ±0 
0 runs   - 40 094  0 ✅  - 40 088  0 💤  - 6  0 ❌ ±0 

Results for commit bc49038. ± Comparison against base commit f298d15.

♻️ This comment has been updated with latest results.

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 31390099814

Warning

No base build found for commit 7c5f77a on main.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 97.183%

Details

  • Patch coverage: 4 of 4 lines across 1 file are fully covered (100%).

Uncovered Changes

No uncovered changes found.

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 13469
Covered Lines: 13243
Line Coverage: 98.32%
Relevant Branches: 4386
Covered Branches: 4109
Branch Coverage: 93.68%
Branches in Coverage %: Yes
Coverage Strength: 67097.42 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

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>
@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
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>>

2 participants