Skip to content

feat(mocks): add ReturnsAsync typed factory overload with method parameters#5367

Merged
thomhurst merged 2 commits intomainfrom
feature/returns-async-typed-factory
Apr 4, 2026
Merged

feat(mocks): add ReturnsAsync typed factory overload with method parameters#5367
thomhurst merged 2 commits intomainfrom
feature/returns-async-typed-factory

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

@thomhurst thomhurst commented Apr 4, 2026

Summary

Closes #5361

The Returns method generates typed factory overloads that provide access to the actual method parameters (e.g. Returns((string key) => ...)), but ReturnsAsync was missing equivalent overloads. This meant users had to use .Returns with a blocking .Result call to access parameters in async methods.

Changes

  • Generates a typed ReturnsAsync(Func<T1, ..., Task<TReturn>> factory) overload for async mock methods that have parameters (both Task<T> and ValueTask<T>)
  • Adds ComputedRawReturnWithArgsBehavior and ReturnsRaw(Func<object?[], object?>) in the runtime library to support the generated code
  • Refactors the three-branch typed overload emission in the source generator into a shared EmitTypedOverloads helper
  • Adds 7 new tests covering typed ReturnsAsync, Callback, and Throws overloads on async Task<T> and ValueTask<T> methods

Before

// Had to use .Returns with blocking .Result to access method parameters
mock
    .DoSomethingAsync(Any<Func<Task<string>>>())
    .Returns(factory => factory.Invoke().Result);

After

// Typed ReturnsAsync factory with access to method parameters
mock
    .GetNameAsync(Any<string>())
    .ReturnsAsync((string key) => Task.FromResult($"value-{key}"));

// Also works with ValueTask<T>
mock
    .ComputeAsync(Any<int>())
    .ReturnsAsync((int input) => new ValueTask<int>(input * 10));

Test plan

  • All 96 source generator snapshot tests pass
  • All 739+ mock runtime tests pass (including 7 new tests)
  • Full typed overload coverage matrix verified for async methods:
Overload Task<T> ValueTask<T>
ReturnsAsync(typed factory)
Callback(typed action)
Throws(typed factory)
ReturnsAsync + Then chain

…meters

Closes #5361. Generates a typed ReturnsAsync overload for async methods
with parameters, enabling e.g. `.ReturnsAsync((string key) => Task.FromResult(key))`.

Also refactors the typed overload emission into a shared EmitTypedOverloads
helper and adds tests for typed Callback/Throws on async methods.
@codacy-production
Copy link
Copy Markdown

codacy-production bot commented Apr 4, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 17 complexity

Metric Results
Complexity 17

View in Codacy

TIP This summary will be updated as you push new changes. Give us feedback

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This is a clean, well-scoped feature addition. The implementation is consistent with existing patterns and the test coverage is thorough.

What's Good

Consistency with existing ReturnsAsync pattern
The new GenerateTypedReturnsAsyncOverload generates EnsureSetup().ReturnsRaw(args => (object?)factory(castArgs)), which is exactly consistent with the parameterless factory overload at line 893:

// Existing zero-arg ReturnsAsync (line 893)
EnsureSetup().ReturnsRaw(() => (object?)taskFactory())
// New typed-param ReturnsAsync (this PR)
EnsureSetup().ReturnsRaw(args => (object?)factory(castArgs))

This symmetry is the right design — the Task<T>/ValueTask<T> object is returned as-is (not awaited), which is correct since the mock method's return type is the task.

EmitTypedOverloads refactor
The extraction into a shared helper reduces ~15 lines of duplication cleanly and correctly threads isAsync/fullReturnType through both the NET9+ and fallback branches. The resulting call sites at lines 214–222 are much easier to read.

Test coverage
Seven tests covering Task, ValueTask, chaining with .Then(), Callback, and Throws on async methods — exactly the coverage matrix the feature needs.


One Concern: Null-forgiving on nullable reference types in cast args

In the generated code for Interface_With_Nullable_Reference_Type_Parameters:

EnsureSetup().ReturnsRaw(args => (object?)factory((string?)args[0]!));

The ! null-forgiving operator is applied to a cast to string? (a nullable type). This is pre-existing in Callback and Returns overloads (same BuildCastArgs helper), so this PR doesn't introduce it. But it's technically misleading: a caller passing null for a string? parameter will have args[0] == null, and the ! tells the compiler "not null" on something that can legitimately be null. At runtime it's harmless (the cast to string? accepts null), but it muddies intent.

This is pre-existing technical debt in BuildCastArgs rather than a problem with this PR, but it's worth tracking if the null-handling story for casts is ever cleaned up.


Minor Observation

The fullReturnType parameter passed into EmitTypedOverloads is the full task type (e.g. global::System.Threading.Tasks.Task<string>). For the #if NET9_0_OR_GREATER branch and the #else branch, the same fullReturnType is passed — this is correct since the return type doesn't change across TFMs, only the parameter list differs. Verified in context.


Overall: Solid implementation. The feature fills a real gap (avoiding .Result blocking in mock factories) and the implementation is consistent with existing infrastructure. Good to merge.

BuildCastArgs was generating `(string?)args[0]!` where the `!` claims
non-null on a value that can legitimately be null. Now only applies `!`
for non-nullable target types.
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Review

The previous review noted one concern about the misleading null-forgiving ! operator on nullable cast args — the follow-up commit 051a8aec ("fix: remove misleading null-forgiving operator on nullable cast args") directly addresses it.

Concern Addressed

The CastArg helper now correctly distinguishes:

private static string CastArg(MockParameterModel p, int index)
{
    // For nullable types, skip the null-forgiving operator since the value can legitimately be null.
    // For non-nullable types, ! suppresses the object? -> T conversion warning.
    var bang = p.FullyQualifiedType.EndsWith("?") ? "" : "!";
    return $"({p.FullyQualifiedType})args[{index}]{bang}";
}

This is the right fix. Using EndsWith("?") to detect nullable reference types is a reasonable heuristic at codegen time — it correctly handles string?, object?, T? etc. without needing full Roslyn type analysis.

The snapshot update confirms the generated output is now correct: (string?)args[0] (no bang) vs (string)args[0]! (with bang).

One Minor Note

EndsWith("?") will also match nullable value types like int? — but that's fine since nullable value types also accept null legitimately, so omitting ! is equally correct there.


Overall: Both commits are clean, the concern from the first review has been properly resolved. This PR is ready to merge.

@thomhurst thomhurst enabled auto-merge (squash) April 4, 2026 12:12
@thomhurst thomhurst merged commit a52bf56 into main Apr 4, 2026
15 checks passed
@thomhurst thomhurst deleted the feature/returns-async-typed-factory branch April 4, 2026 12:29
@claude claude bot mentioned this pull request Apr 4, 2026
1 task
intellitect-bot pushed a commit to IntelliTect/EssentialCSharp.Web that referenced this pull request Apr 7, 2026
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.24.18 to
1.28.7.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.28.7

<!-- Release notes generated using configuration in .github/release.yml
at v1.28.7 -->

## What's Changed
### Other Changes
* fix: prevent StringBuilder race in console interceptor during parallel
tests by @​thomhurst in thomhurst/TUnit#5414
### Dependencies
* chore(deps): update tunit to 1.28.5 by @​thomhurst in
thomhurst/TUnit#5415


**Full Changelog**:
thomhurst/TUnit@v1.28.5...v1.28.7

## 1.28.5

<!-- Release notes generated using configuration in .github/release.yml
at v1.28.5 -->

## What's Changed
### Other Changes
* perf: eliminate redundant builds in CI pipeline by @​thomhurst in
thomhurst/TUnit#5405
* perf: eliminate store.ToArray() allocation on mock behavior execution
hot path by @​thomhurst in thomhurst/TUnit#5409
* fix: omit non-class/struct constraints on explicit interface mock
implementations by @​thomhurst in
thomhurst/TUnit#5413
### Dependencies
* chore(deps): update tunit to 1.28.0 by @​thomhurst in
thomhurst/TUnit#5406


**Full Changelog**:
thomhurst/TUnit@v1.28.0...v1.28.5

## 1.28.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.28.0 -->

## What's Changed
### Other Changes
* fix: resolve build warnings in solution by @​thomhurst in
thomhurst/TUnit#5386
* Perf: Optimize MockEngine hot paths (~30-42% faster) by @​thomhurst in
thomhurst/TUnit#5391
* Move Playwright install into pipeline module by @​thomhurst in
thomhurst/TUnit#5390
* perf: optimize solution build performance by @​thomhurst in
thomhurst/TUnit#5393
* perf: defer per-class JIT via lazy test registration + parallel
resolution by @​thomhurst in
thomhurst/TUnit#5395
* Perf: Generate typed HandleCall<T1,...> overloads to eliminate
argument boxing by @​thomhurst in
thomhurst/TUnit#5399
* perf: filter generated attributes to TUnit-related types only by
@​thomhurst in thomhurst/TUnit#5402
* fix: generate valid mock class names for generic interfaces with
non-built-in type args by @​thomhurst in
thomhurst/TUnit#5404
### Dependencies
* chore(deps): update tunit to 1.27.0 by @​thomhurst in
thomhurst/TUnit#5392
* chore(deps): update dependency path-to-regexp to v8 by @​thomhurst in
thomhurst/TUnit#5378


**Full Changelog**:
thomhurst/TUnit@v1.27.0...v1.28.0

## 1.27.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.27.0 -->

## What's Changed
### Other Changes
* Fix Dependabot security vulnerabilities in docs site by @​thomhurst in
thomhurst/TUnit#5372
* fix: use 0.0.0-scrubbed sentinel version in snapshot scrubber to avoid
false Dependabot alerts by @​thomhurst in
thomhurst/TUnit#5374
* Speed up Engine.Tests by removing ProcessorCount parallelism cap by
@​thomhurst in thomhurst/TUnit#5379
* ci: add concurrency groups to cancel redundant workflow runs by
@​thomhurst in thomhurst/TUnit#5373
* Add scope-aware initialization and disposal OpenTelemetry spans to
trace timeline and HTML report by @​Copilot in
thomhurst/TUnit#5339
* Add WithInnerExceptions() for fluent AggregateException assertion
chaining by @​thomhurst in thomhurst/TUnit#5380
* Drop net6.0 and net7.0 TFMs, keep net8.0+ and netstandard2.x by
@​thomhurst in thomhurst/TUnit#5387
* Remove all [Obsolete] members and migrate callers by @​thomhurst in
thomhurst/TUnit#5384
* Add AssertionResult.Failed overload that accepts an Exception by
@​thomhurst in thomhurst/TUnit#5388
### Dependencies
* chore(deps): update dependency mockolate to 2.3.0 by @​thomhurst in
thomhurst/TUnit#5370
* chore(deps): update tunit to 1.25.0 by @​thomhurst in
thomhurst/TUnit#5371
* chore(deps): update dependency minimatch to v9.0.9 by @​thomhurst in
thomhurst/TUnit#5375
* chore(deps): update dependency path-to-regexp to v0.2.5 by @​thomhurst
in thomhurst/TUnit#5376
* chore(deps): update dependency minimatch to v10 by @​thomhurst in
thomhurst/TUnit#5377
* chore(deps): update dependency picomatch to v4 by @​thomhurst in
thomhurst/TUnit#5382
* chore(deps): update dependency svgo to v4 by @​thomhurst in
thomhurst/TUnit#5383
* chore(deps): update dependency path-to-regexp to v1 [security] by
@​thomhurst in thomhurst/TUnit#5385


**Full Changelog**:
thomhurst/TUnit@v1.25.0...v1.27.0

## 1.25.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.25.0 -->

## What's Changed
### Other Changes
* Fix missing `default` constraint on explicit interface implementations
with unconstrained generics by @​thomhurst in
thomhurst/TUnit#5363
* feat(mocks): add ReturnsAsync typed factory overload with method
parameters by @​thomhurst in
thomhurst/TUnit#5367
* Fix Arg.IsNull<T> and Arg.IsNotNull<T> to support nullable value types
by @​thomhurst in thomhurst/TUnit#5366
* refactor(mocks): use file-scoped types for generated implementation
details by @​thomhurst in thomhurst/TUnit#5369
* Compress HTML report JSON data and minify CSS by @​thomhurst in
thomhurst/TUnit#5368
### Dependencies
* chore(deps): update tunit to 1.24.31 by @​thomhurst in
thomhurst/TUnit#5356
* chore(deps): update dependency mockolate to 2.2.0 by @​thomhurst in
thomhurst/TUnit#5357
* chore(deps): update dependency polyfill to 9.24.1 by @​thomhurst in
thomhurst/TUnit#5365
* chore(deps): update dependency polyfill to 9.24.1 by @​thomhurst in
thomhurst/TUnit#5364


**Full Changelog**:
thomhurst/TUnit@v1.24.31...v1.25.0

## 1.24.31

<!-- Release notes generated using configuration in .github/release.yml
at v1.24.31 -->

## What's Changed
### Other Changes
* Fix Aspire 13.2.0+ timeout caused by ProjectRebuilderResource being
awaited by @​Copilot in thomhurst/TUnit#5335
* chore(deps): update dependency polyfill to 9.24.0 by @​thomhurst in
thomhurst/TUnit#5349
* Fix nullable IParsable type recognition in source generator and
analyzer by @​Copilot in thomhurst/TUnit#5354
* fix: resolve race condition in HookExecutionOrderTests by @​thomhurst
in thomhurst/TUnit#5355
* Fix MaxExternalSpansPerTest cap bypass when Activity.Parent chain is
broken by @​Copilot in thomhurst/TUnit#5352
### Dependencies
* chore(deps): update tunit to 1.24.18 by @​thomhurst in
thomhurst/TUnit#5340
* chore(deps): update dependency stackexchange.redis to 2.12.14 by
@​thomhurst in thomhurst/TUnit#5343
* chore(deps): update verify to 31.15.0 by @​thomhurst in
thomhurst/TUnit#5346
* chore(deps): update dependency polyfill to 9.24.0 by @​thomhurst in
thomhurst/TUnit#5348


**Full Changelog**:
thomhurst/TUnit@v1.24.18...v1.24.31

Commits viewable in [compare
view](thomhurst/TUnit@v1.24.18...v1.28.7).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit&package-manager=nuget&previous-version=1.24.18&new-version=1.28.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This was referenced Apr 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: ReturnsAsync factory with actual method parameters for mocks

1 participant