Support asserting on delegates that return a value task - #3301
Open
dennisdoomen wants to merge 1 commit into
Open
Support asserting on delegates that return a value task#3301dennisdoomen wants to merge 1 commit into
dennisdoomen wants to merge 1 commit into
Conversation
dennisdoomen
requested a deployment
to
qodana-pr
August 10, 2026 12:53 — with
GitHub Actions
Waiting
dennisdoomen
marked this pull request as draft
August 10, 2026 13:01
Coverage Report for CI Build 31390099814Warning No base build found for commit Coverage: 97.183%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsRequires a base build to compare against. How to fix this → Coverage Stats💛 - Coveralls |
dennisdoomen
had a problem deploying
to
qodana-pr
August 10, 2026 14:55 — with
GitHub Actions
Failure
Qodana for .NETIt 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 Contact Qodana teamContact us at qodana-support@jetbrains.com
|
dennisdoomen
force-pushed
the
valuetask-should-support
branch
from
August 15, 2026 09:23
f50cf7f to
8c7b5d7
Compare
dennisdoomen
requested a deployment
to
qodana-pr
August 15, 2026 09:24 — with
GitHub Actions
Waiting
dennisdoomen
force-pushed
the
valuetask-should-support
branch
from
August 15, 2026 09:25
8c7b5d7 to
24f579a
Compare
dennisdoomen
requested a deployment
to
qodana-pr
August 15, 2026 09:25 — with
GitHub Actions
Waiting
dennisdoomen
force-pushed
the
valuetask-should-support
branch
from
August 15, 2026 09:26
24f579a to
cfc406f
Compare
dennisdoomen
requested a deployment
to
qodana-pr
August 15, 2026 09:26 — with
GitHub Actions
Waiting
dennisdoomen
force-pushed
the
valuetask-should-support
branch
from
August 15, 2026 09:33
cfc406f to
1f9e8ab
Compare
dennisdoomen
requested a deployment
to
qodana-pr
August 15, 2026 09:33 — with
GitHub Actions
Waiting
dennisdoomen
force-pushed
the
valuetask-should-support
branch
from
August 15, 2026 09:38
1f9e8ab to
f6d8d9d
Compare
dennisdoomen
requested a deployment
to
qodana-pr
August 15, 2026 09:38 — with
GitHub Actions
Waiting
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
force-pushed
the
valuetask-should-support
branch
from
August 15, 2026 09:42
f6d8d9d to
bc49038
Compare
dennisdoomen
marked this pull request as ready for review
August 15, 2026 09:42
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Asserting on a delegate that returns a value task previously meant going through the
Awaitingadapter 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, soThrowAsync,NotThrowAsync,CompleteWithinAsyncand friends work straight off aFunc<ValueTask>orFunc<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.AssertionExtensionsalready has a catch-all overload:A
Func<ValueTask>binds to it withT = ValueTask, and aFunc<ValueTask<int>>binds withT = ValueTask<int>. So today this compiles: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: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, becauseSubjectno longer returns the delegate you passed in:This one I could not avoid. The asynchronous assertions derive from
AsyncFunctionAssertions<TTask, TAssertions> where TTask : Task, soSubjectis aFunc<TTask>and aFunc<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/mainin Release, compiled a scratch console app against the resultingFluentAssertions.dll, then swapped the reference to this branch's build and recompiled the same call sites.act.Should().NotThrow()compiles againstmainand fails withCS1061against this branch. Reflection overFunctionAssertions<ValueTask>versusNonGenericAsyncFunctionAssertionsproduced the member table above.What I would like you to decide
The
api-approvedlabel 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
FluentAssertions.Specs(6036 tests on net8.0/net6.0, 111 on net47),FluentAssertions.Equivalency.Specsand the API approval tests all pass.upstream/main; the only conflict was in the release notes, where this PR's "What's new" entry collided with a newly landed entry forHaveLineCount/ContainLine(Add HaveLineCount()/NotHaveLineCount() and ContainLine()/NotContainLine() to StringAssertions #3297) — resolved by keeping both.