feat(mocks): parameterless Returns() and ReturnsAsync() for async methods#5309
feat(mocks): parameterless Returns() and ReturnsAsync() for async methods#5309
Conversation
…Async() for async methods Add parameterless Returns() to void/Task/ValueTask method setups, enabling sequential behavior chains like .Returns().Then.Throws<Exception>().Then.Returns(). Add ReturnsAsync() for Task/Task<T>/ValueTask/ValueTask<T> methods, allowing users to return pre-built Task instances directly (e.g. from TaskCompletionSource) for controlled timing in mock tests. Uses a RawReturn marker type with thread-local RawReturnContext side-channel, following the established OutRefContext pattern.
There was a problem hiding this comment.
Code Review
Overall this is a well-designed feature that extends the mock framework naturally. The implementation is consistent with the existing OutRefContext side-channel pattern, the test coverage is thorough, and the VoidReturnBehavior singleton is a clean design. A few things worth discussing:
Architectural Concern: Thread-local side-channel contract is implicit
The RawReturnContext thread-local pattern works, but the correctness depends on an implicit contract: the generated code must consume the context synchronously (with no await) between HandleCall* and TryConsume. This contract isn't documented, and future changes (e.g., async behavior callbacks, or async MockEngine dispatch) could silently break it.
I'd suggest adding an explicit contract comment in RawReturnContext and in EmitRawReturnCheck:
// IMPORTANT: RawReturnContext must be consumed in the same synchronous execution
// context as HandleCall*. No await may appear between these two calls.The existing OutRefContext has the same implicit contract, so this isn't a new risk — but it's worth surfacing since RawReturn is specifically designed for async scenarios where developers are more likely to think about async/await boundaries.
Design: Double-wrapping in RawReturnContext
There's a minor redundancy. MockEngine extracts .Value from the RawReturn returned by Execute, then RawReturnContext.Set(raw.Value) re-wraps it in a new RawReturn:
// MockEngine.cs
if (result is RawReturn raw)
{
RawReturnContext.Set(raw.Value); // unwrap, then Set re-wraps in new RawReturn
}
// RawReturnContext.Set
public static void Set(object? value) => _pending = new RawReturn(value); // re-wrapsThe _pending field type could be simplified to object? with a sentinel approach (e.g., a static readonly object _noValue = new()), or Set could accept a RawReturn directly to avoid the extra allocation:
// Alternative - avoid re-wrapping
[ThreadStatic]
private static object? _pending;
private static readonly object _notSet = new();
// Initialize: _pending = _notSet;
public static void Set(object? value) => _pending = value;
public static bool TryConsume(out object? value) {
if (!ReferenceEquals(_pending, _notSet)) {
value = _pending;
_pending = _notSet;
return true;
}
value = null;
return false;
}This avoids the extra RawReturn allocation on every ReturnsAsync call. Admittedly minor for test code, but the current pattern of unwrapping and re-wrapping is slightly confusing to read.
Potential runtime error: untyped cast in generated code
The generated TryConsume check uses a null-forgiving direct cast:
// EmitRawReturnCheck generates:
if (global::TUnit.Mocks.Setup.RawReturnContext.TryConsume(out var __rawAsync)) return (Task<int>)__rawAsync!;If a user accidentally configures ReturnsAsync(wrongTypeTask) (e.g., passes Task<string> where Task<int> is expected), this throws an InvalidCastException with a confusing message. A as + null check with a clear InvalidOperationException would give better diagnostics:
if (global::TUnit.Mocks.Setup.RawReturnContext.TryConsume(out var __rawAsync))
{
if (__rawAsync is not {ReturnType} __typed)
throw new global::System.InvalidOperationException(
$"ReturnsAsync: expected {nameof({ReturnType})} but got {{__rawAsync?.GetType().Name ?? \"null\"}}");
return __typed;
}Snapshot tests
The PR modifies MockMembersBuilder.cs and MockImplBuilder.cs in the source generator, which should produce new/changed generated output. Are there .verified.txt snapshot updates included? I don't see them in the file list — if existing snapshots break, the CI will fail.
What's working well
VoidReturnBehaviorsingleton — clean, allocation-free, no state needed.PropertySetterSetupBuilder.Returns()— correctly delegates to the void inner builder.- Test coverage — tests cover TCS timing, factory overload, mixed sequences, and already-completed tasks. The
Void_Returns_Then_Throws_Then_Returnstest clearly validates the original motivation. [EditorBrowsable(Never)]onReturnsRaw— keeps the internal plumbing hidden while keeping the publicReturnsAsyncsurface clean.- Clear/Set at dispatch entry — the
RawReturnContext.Clear()at the top of eachHandleCall*method prevents stale state leaks across calls.
- Eliminate double-wrapping: RawReturnContext.Set() now accepts RawReturn directly instead of unwrapping and re-wrapping - Add contract comment documenting synchronous consumption requirement - Generated RawReturn cast now uses pattern match with descriptive InvalidOperationException instead of null-forgiving cast - Update 7 snapshot files for new generated code
There was a problem hiding this comment.
Code Review
Previous comments — status
Both concerns from my earlier review have been addressed:
-
InvalidCastExceptionwith confusing message — Fixed. Generated code now usesispattern matching with a descriptiveInvalidOperationException, e.g.:if (__rawAsync is global::System.Threading.Tasks.Task<string> __typedAsync) return __typedAsync; throw new global::System.InvalidOperationException($"ReturnsAsync: expected ... but got ...");
This is exactly the right approach.
-
Snapshot tests —
.verified.txtfiles are updated throughout, CI should be happy.
The sentinel-value optimization I mentioned is minor for test code and not a blocker.
New concern: ValueTask single-use invariant with the direct-value overload
ValueTask<T> has a documented contract: it must be awaited at most once. The generated ReturnsAsync(ValueTask<T> task) overload stores the struct by value (boxed to object? in RawReturn), and unboxes it on every call:
// RawReturnBehavior stores the value once at setup time
public RawReturnBehavior(object? rawValue) => _wrapper = new RawReturn(rawValue);
// Every call to the mock unboxes and returns the same ValueTask
if (__rawAsync is global::System.Threading.Tasks.ValueTask<int> __typedAsync) return __typedAsync;If the mock method is called more than once, the same ValueTask struct is handed back each time. For ValueTask<T> backed by a Task<T> (as in the tests), this is safe — Task<T> can be awaited multiple times. But for ValueTask<T> backed by an IValueTaskSource<T>, awaiting twice causes undefined behaviour per the BCL contract.
The factory overload (ReturnsAsync(Func<ValueTask<T>>)) is safe because it creates a fresh ValueTask each call. The direct-value overload is a potential foot-gun worth calling out. At minimum, a <remarks> doc comment should warn that this overload should only be used when the task will be awaited once, or when it's backed by a Task<T>.
Minor: boxing round-trip for ValueTask structs
ValueTask and ValueTask<T> are value types. The current path is:
- User calls
ReturnsAsync(vtask)→ struct is boxed toobject?innew RawReturn(rawValue) - On mock call:
RawReturnContext.TryConsume(out var __rawAsync)returns the boxed object - Generated code does
__rawAsync is ValueTask<int> __typedAsync→ unboxes
For test code this is negligible, but it's worth knowing the overhead is there.
Design: the [ThreadStatic] side-channel contract is sound but fragile by nature
The RawReturnContext side-channel requires that no await appear between the engine's HandleCall* and the generated TryConsume. The remarks XML doc on RawReturnContext documents this clearly, and the generated code upholds it. The Clear() call at dispatch entry prevents stale values leaking between calls.
This is the right approach given the constraints (matching the established OutRefContext pattern, no change to the engine's object? return type), and the code generation guarantees the invariant for all current paths.
What's working well
VoidReturnBehaviorsingleton — zero-allocation no-op, correct design.Returns()on all void mock calls — enables the.Then.Throws.Then.Returns()chains that motivated this PR.[EditorBrowsable(Never)]onReturnsRaw— keeps internal plumbing hidden from IDE completions while exposing the cleanReturnsAsyncsurface in generated code.Clear()at every dispatch entry — prevents cross-call pollution.- Test coverage — all meaningful scenarios are covered: TCS timing, factory overloads, mixed sequences, and already-completed tasks.
- Snapshot files updated — no missing
.verified.txtfiles.
The main ask before merging is a doc comment (or at least a <remarks>) warning about the ValueTask single-use caveat on the direct-value ReturnsAsync(ValueTask<T>) overload.
ValueTask may only be awaited once. The direct-value ReturnsAsync overload returns the same instance on every call, which is safe when backed by a Task but undefined for IValueTaskSource. Add remarks doc comment warning users to prefer the factory overload for multi-call scenarios.
There was a problem hiding this comment.
Code Review: feat(mocks): parameterless Returns() and ReturnsAsync() for async methods
Overall this is a well-thought-out feature and the test coverage is solid. The RawReturnContext side-channel pattern is a natural extension of the existing OutRefContext approach. That said, there are several design concerns worth discussing before merging.
1. Thread-local side-channel is fragile with async continuations
The biggest concern is the correctness contract for RawReturnContext. The [ThreadStatic] approach works only because the TryConsume() call happens before any await — which is guaranteed by the generated code today. But this is a hidden, non-obvious invariant.
The doc comment in RawReturn.cs does acknowledge this:
IMPORTANT: RawReturnContext must be consumed synchronously in the same execution context as HandleCall*/TryHandleCall*.
However, there is nothing enforcing it. If a future code-gen change, a refactor, or a class-based mock with virtual async methods dispatches through the engine and then awaits before checking the context, the ThreadStatic value will silently be read from the wrong thread (or missed entirely after a thread-pool continuation resumes on a different thread). This is the same fragility that affects OutRefContext, but OutRefContext is only relevant for synchronous out-param readback; here the value is a Task/ValueTask that may be partially completed, making a missed consume much harder to debug.
Suggestion: Consider encoding the raw-return value in the return value of HandleCall/HandleCallWithReturn rather than a side-channel. For example, HandleCallWithReturn could accept a special sentinel TReturn value, or the engine could expose a separate HandleCallWithRawReturn overload. This would make the contract explicit and survive async continuations correctly.
Alternatively, document the invariant prominently in the generated EmitRawReturnCheck code itself (as a comment) and in IBehavior, so maintainers understand it cannot be moved past an await.
2. RawReturnContext.Clear() at entry vs. correct teardown on exception
In MockEngine, every Handle* entry point calls RawReturnContext.Clear() first. But if the behavior is a RawReturnBehavior, Set() is called, and then an exception is thrown (e.g., from RaiseEventsForSetup), the context is not cleared before the exception escapes. On the next call, Clear() runs first — so the stale value is cleaned up. However, in the HandleCall (void) path:
var behaviorResult = behavior.Execute(args);
if (behaviorResult is RawReturn raw)
{
RawReturnContext.Set(raw);
}
// Set out/ref assignments after Execute to avoid reentrancy overwrite from callbacks
OutRefContext.Set(matchedSetup?.OutRefAssignments);
if (matchedSetup is not null)
{
RaiseEventsForSetup(matchedSetup); // <-- can throw
}
return;If RaiseEventsForSetup throws after RawReturnContext.Set(raw), the raw value is left in the context. Because the generated code never had a chance to TryConsume() it (the exception escaped), the stale value lives until the next HandleCall clears it. This could cause a subsequent unrelated call to incorrectly pick it up if the generated code is ever restructured. Consider always clearing on exception paths (e.g., a try/finally in the engine).
3. VoidReturnBehavior — Returns() for void methods is a silent no-op that loses its purpose
VoidReturnBehavior.Instance.Execute(...) returns null, which is indistinguishable from a method with no setup behavior returning its default. The important part of Returns() in a .Then() chain is that it advances the behavior index (because a behavior was added). That's correct — MethodSetup.GetNextBehavior() advances _callIndex on each call, so adding VoidReturnBehavior to the list is sufficient to make sequencing work.
But this means Returns() can be called without .Then() and still consumes an index slot. For example:
mock.Log(Any()).Returns().Returns().Throws<Exception>();would configure three behaviors: [VoidReturn, VoidReturn, Throw]. The second Returns() call is probably a user mistake (they forgot .Then() between them), but it silently works. This is a discoverability issue rather than a bug, but it is different from how Returns(value) works on return-value methods. Consider a guard or at minimum a doc comment explaining this.
4. GenerateVoidUnifiedClass emits Returns() unconditionally
In MockMembersBuilder.cs, Returns() is emitted for all void wrappers, regardless of the isAsync parameter:
writer.AppendLine($"public {wrapperName} Returns() {{ EnsureSetup().Returns(); return this; }}");
writer.AppendLine($"/// <inheritdoc />");This is correct behavior (synchronous void methods should also support Returns() for sequencing), but the diff shows Returns() being added to the snapshot output for non-async methods like INotifier_Notify_M0_MockCall and IDictionary_Swap_M1_MockCall. Was Returns() intentionally not present on those before? If so, this is a breaking change for users who had code that didn't find Returns() and reached the non-generated VoidMockMethodCall path. It's likely desirable, but it should be called out explicitly.
5. Boxing ValueTask structs into object?
In EmitReturnsAsyncOverloads:
writer.AppendLine($"public {wrapperName} ReturnsAsync(global::System.Func<{taskType}> taskFactory) {{ EnsureSetup().ReturnsRaw(() => (object?)taskFactory()); return this; }}");For ValueTask and ValueTask<T>, the factory lambda boxes the struct on every invocation. This is in a mock setup path (not the hot execution path), so it is not a critical performance issue. But ComputedRawReturnBehavior also boxes on every Execute() call (new RawReturn(_factory())), which happens on each mock invocation. For ValueTask, boxing means allocating — and ValueTask was designed to avoid allocation. Since this is mocking infrastructure it may be acceptable, but it's worth documenting the tradeoff, especially given the project's AOT/performance posture.
For the non-factory single-instance overload, ValueTask is boxed once at setup time, which is a lesser concern.
6. ShouldGenerateTypedWrapper change silently widens scope
// Async methods need a typed wrapper for the generated ReturnsAsync() method
if (method.IsAsync) return true;This causes all async methods to generate typed wrappers, even generic async methods — wait, generic methods are short-circuited above this. But it also affects parameterless async methods (zero non-out params) and methods with more than MaxTypedParams arguments. Before this PR, those would have used the untyped/void fallback. Now they always get typed wrappers. This is intentional (for ReturnsAsync access) but the comment only mentions the ReturnsAsync() need. The expansion in generated output is significant, as the snapshots show: DoWorkAsync (which is void Task) now gets a full typed wrapper class with ~20 methods, where before it returned VoidMockMethodCall. This multiplies generated code size for every async method. Consider noting this tradeoff in the PR description and checking whether MaxTypedParams logic should still apply (perhaps only the ReturnsAsync + Returns() on void async could be added to VoidMockMethodCall without forcing a full typed wrapper).
7. Missing ReturnsAsync on IVoidSetupChain / IMethodSetup<T> interfaces
ReturnsRaw is added to VoidMethodSetupBuilder and MethodSetupBuilder<T>, but not to the IVoidMethodSetup, IVoidSetupChain, or IMethodSetup<T> interfaces. That's fine since it's [EditorBrowsable(Never)] and only intended for generated code. But Returns() is now added to IVoidMethodSetup:
public interface IVoidMethodSetup
{
/// <summary>Configure the method to return normally (no-op). Useful for sequential behavior chains.</summary>
IVoidSetupChain Returns();This is a public interface change. Any external code implementing IVoidMethodSetup (unlikely in practice, but possible) will now require implementing Returns(). Since IVoidMethodSetup was previously internal-leaning ([EditorBrowsable(Never)]), this is low risk but should be acknowledged as a breaking interface change.
8. Minor: EmitRawReturnCheck indentation uses literal spaces
writer.AppendLine($"if (global::TUnit.Mocks.Setup.RawReturnContext.TryConsume(out var __rawAsync))");
writer.AppendLine("{");
writer.AppendLine($" if (__rawAsync is {method.ReturnType} __typedAsync) return __typedAsync;");
writer.AppendLine($" throw new global::System.InvalidOperationException(...)");
writer.AppendLine("}");The body uses hardcoded 4-space indent rather than writer.IncreaseIndent() / writer.DecreaseIndent() or the writer.Block() helper. This is inconsistent with the rest of the builder and will produce incorrectly indented output if the writer's current indent level is not zero. Looking at the snapshot output, it appears the check is inside a try block (one level of indentation already), yet the generated output shows correct alignment — but this is fragile. Use writer.Block() or IncreaseIndent/DecreaseIndent for correctness and consistency.
Summary
The feature solves a real usability gap and the pattern follows the existing OutRefContext precedent. The main risks are:
- Thread-safety correctness of the
[ThreadStatic]side-channel (manageable but needs better documentation or an alternative design) - Generated code size increase from forcing all async methods into typed wrappers
- Indentation inconsistency in
EmitRawReturnCheck - Interface break on
IVoidMethodSetup
The test coverage is comprehensive. Items 3 and 4 are easy fixes; items 1 and 2 are worth a design conversation before merging.
…entation - Wrap post-Set code in try/catch in HandleCall and TryHandleCall to clear RawReturnContext if RaiseEventsForSetup throws, preventing stale values from leaking to subsequent calls - Use writer.OpenBrace()/CloseBrace() in EmitRawReturnCheck instead of hardcoded braces for consistent indentation at any nesting level - Add inline comment about synchronous consumption contract
There was a problem hiding this comment.
Code Review
Previous comments — status
Comparing against my earlier reviews, most open issues have been addressed:
InvalidCastExceptionwith confusing message ✅ — Usesispattern matching with descriptiveInvalidOperationException. (if (__rawAsync is Task<string> __typedAsync) return __typedAsync; throw ...)- Double-wrapping in
RawReturnContext✅ —Setnow acceptsRawReturndirectly to avoid re-wrapping. - Indentation in
EmitRawReturnCheck✅ — Useswriter.OpenBrace()/writer.CloseBrace(); snapshot output confirms correct alignment. RawReturnContext.Clear()on exception paths ✅ —try/catch { RawReturnContext.Clear(); throw; }wraps theOutRefContextand event-raising code.ValueTasksingle-use warning ✅ —\<remarks\>doc correctly warns about the factory overload and backing Task requirement.- Snapshot files updated ✅ — All
.verified.txtfiles are present.
Remaining open concern: generated code size from ShouldGenerateTypedWrapper
This was raised in the previous review and is not addressed:
// Async methods need a typed wrapper for the generated ReturnsAsync() method
if (method.IsAsync) return true;This forces every async method — including parameterless ones, out-param methods, and methods with many args that previously used the lightweight VoidMockMethodCall path — to generate a full typed sealed class with ~20 methods. The DoWorkAsync example in the snapshot shows this clearly: it now emits a complete IAsyncService_DoWorkAsync_M1_MockCall class where previously a VoidMockMethodCall was returned.
For projects with many async interface methods this will meaningfully increase source-generator output size and compile time. This is a tradeoff worth documenting explicitly. The alternative would be to add Returns() and ReturnsAsync() directly to VoidMockMethodCall (for void async) and keep the light-weight path for the non-ReturnsAsync-needing cases, only generating a typed wrapper when the interface actually has out/ref params or typed callbacks — the same gating logic that existed before. But I understand this could require more complex branching in the builder.
Minor: Returns() silently advances the behavior slot without .Then()
// Compiles and runs, but probably a user mistake — 3 slots, not 2
mock.Log(Any()).Returns().Returns().Throws<Exception>();VoidReturnBehavior is correct as a no-op placeholder for .Then() sequences. But calling Returns() twice without .Then() in between silently consumes two behavior slots and the second Throws only fires on the third call. This is a design-level gotcha that's shared with all chaining APIs (the .Returns(v).Returns(v) pattern on return-value methods has the same behavior). A doc comment on VoidReturnBehavior or on the generated Returns() methods noting "intended for use with .Then() chains" would reduce user surprise.
What's working well
The implementation is solid. The RawReturnContext side-channel follows the OutRefContext precedent, the VoidReturnBehavior singleton is allocation-free, the test coverage is thorough (TCS timing, factory overloads, mixed sequences, already-completed tasks), and the [EditorBrowsable(Never)] on ReturnsRaw keeps the internals appropriately hidden. The feature solves a real usability gap in sequential behavior chains for void/async methods.
The typed-wrapper size tradeoff is the one remaining question worth a deliberate decision before merging.
…r net472 Task.IsCompletedSuccessfully is not available on .NET Framework 4.7.2. Use TaskStatus.RanToCompletion check instead.
There was a problem hiding this comment.
Review: feat(mocks): parameterless Returns() and ReturnsAsync() for async methods
Overall this is a well-thought-out feature with good test coverage. The design follows established patterns in the codebase. I have a few observations ranging from correctness concerns to design questions worth considering.
1. Thread-local side-channel: AOT and async runtime concerns
The RawReturnContext uses [ThreadStatic] as a side-channel between HandleCall* and the generated code. The PR comments correctly note that no await may appear between dispatch and TryConsume. This contract is currently fragile — it is enforced purely by documentation and careful code generation, and could silently break if:
- The generated
catchblock in the mock implementation introduces an await (unlikely, but possible in future generators), - A
SynchronizationContextor thread-pool hop occurs betweenHandleCall*returning andTryConsume(very edge-case, but.ConfigureAwait(false)could theoretically be in play in some hosting environments), - Someone writes a custom
IBehaviorthat itself awaits.
The existing OutRefContext has the same shape and the same fragility, so this is consistent. But it is worth noting that this pattern does not compose safely with arbitrary async contexts. The existing comment documents the requirement, which is good — just flagging it as an area that may need reconsideration if the mock framework ever adds support for intercepting async IAsyncEnumerable or similar.
2. HandleCallWithReturn<TReturn> missing try/catch around RaiseEventsForSetup
In HandleCall and TryHandleCall, the new code wraps RaiseEventsForSetup in a try/catch that clears RawReturnContext on exception:
try
{
OutRefContext.Set(matchedSetup?.OutRefAssignments);
if (matchedSetup is not null) RaiseEventsForSetup(matchedSetup);
}
catch
{
RawReturnContext.Clear();
throw;
}However, in HandleCallWithReturn<TReturn> the equivalent code does not have this guard:
if (result is RawReturn raw)
{
RawReturnContext.Set(raw);
return defaultValue;
}The RaiseEventsForSetup call that precedes the RawReturn check in HandleCallWithReturn is:
OutRefContext.Set(matchedSetup?.OutRefAssignments);
if (matchedSetup is not null) RaiseEventsForSetup(matchedSetup); // <-- no try/catch
if (result is TReturn typed) return typed;
if (result is null) return default(TReturn)!;
if (result is RawReturn raw) { RawReturnContext.Set(raw); return defaultValue; }If RaiseEventsForSetup throws, RawReturnContext was already set on the previous line and will never be cleared, leaking into the next call. The same applies to TryHandleCallWithReturn. These two methods should receive the same try/catch treatment.
3. RawReturn is public but Value has no access restriction
RawReturn.Value is public, and RawReturn itself is marked [EditorBrowsable(Never)]. This is fine for generated code consumption, but Value returns object? which means any consumer could extract the inner Task before it is meant to be returned. This is minor — the [EditorBrowsable] suppression is the practical barrier — but it could be internal with InternalsVisibleTo for the generated assembly if AOT linker rules permit, or at least documented.
4. VoidReturnBehavior.Instance allocation is skipped, but RawReturnBehavior allocates on every ReturnsAsync setup
VoidReturnBehavior is a singleton (static Instance), which is excellent. However, RawReturnBehavior captures the RawReturn wrapper at construction time:
public RawReturnBehavior(object? rawValue) => _wrapper = new RawReturn(rawValue);This means each call to .ReturnsAsync(someTask) allocates both a RawReturnBehavior and a RawReturn. Since these are setup-time (not hot-path) allocations this is acceptable, but it is slightly asymmetric with how ReturnBehavior<T> works (which boxes the value directly). Not a blocker, just something to be aware of.
5. Returns() on VoidMethodSetupBuilder is unconditionally emitted for all void wrappers
In MockMembersBuilder.GenerateVoidUnifiedClass, the new Returns() line is emitted unconditionally:
writer.AppendLine($"public {wrapperName} Returns() {{ EnsureSetup().Returns(); return this; }}");This is emitted for all void wrappers, including property setters (via PropertySetterSetupBuilder). The PropertySetterSetupBuilder now also delegates Returns() to _inner.Returns(). For a property setter, .Returns() semantically means "do nothing when this property is set", which is actually reasonable. However, it may surprise users that mock.MyProperty = value can be configured with .Returns() in a sequence. Worth a brief mention in docs/changelog that this applies to setters too.
6. ShouldGenerateTypedWrapper now returns true for all async methods regardless of parameters
The change:
// Async methods need a typed wrapper for the generated ReturnsAsync() method
if (method.IsAsync) return true;This means async methods with zero non-out parameters, or with ref-struct parameters, now get typed wrappers when previously they might not have. The snapshot tests cover this (e.g. DoWorkAsync now getting its own typed class). This is correct behavior given the new ReturnsAsync() API. I just note that it will slightly increase generated code size for interfaces with many no-parameter async void-returning methods — not a practical concern but worth knowing.
7. Minor: EmitRawReturnCheck placement relative to EmitOutRefReadback
In GeneratePartialMethodBody and GenerateEngineDispatchBody, the order is:
EmitOutRefReadbackEmitRawReturnCheckreturn default/return Task.CompletedTask
This is correct. However the comment above EmitRawReturnCheck could explicitly note why it must come after EmitOutRefReadback (because out-ref assignments should be written even when returning a raw Task, so the caller sees parameter assignments). Currently this is implied but not stated.
Summary
The implementation is clean and consistent with existing patterns. The main correctness issue is point 2 — HandleCallWithReturn and TryHandleCallWithReturn need the same try/catch guard around RaiseEventsForSetup to avoid leaking stale RawReturnContext state when event raising throws. All other points are minor design observations or documentation suggestions.
…hReturn methods HandleCallWithReturn and TryHandleCallWithReturn now clear RawReturnContext on exception from RaiseEventsForSetup, matching the existing pattern in HandleCall and TryHandleCall. Prevents stale state leaking across calls.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.23.7 to 1.24.13. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.24.13 <!-- Release notes generated using configuration in .github/release.yml at v1.24.13 --> ## What's Changed ### Other Changes * perf(mocks): optimize MockEngine for lower allocation and faster verification by @thomhurst in thomhurst/TUnit#5319 * Remove defunct `UseTestingPlatformProtocol` reference for vscode by @erwinkramer in thomhurst/TUnit#5328 * perf(aspnetcore): prevent thread pool starvation during parallel WebApplicationTest server init by @thomhurst in thomhurst/TUnit#5329 * fix TUnit0073 for when type from from another assembly by @SimonCropp in thomhurst/TUnit#5322 * Fix implicit conversion operators bypassed in property injection casts by @Copilot in thomhurst/TUnit#5317 * fix(mocks): skip non-virtual 'new' methods when discovering mockable members by @thomhurst in thomhurst/TUnit#5330 * feat(mocks): IFoo.Mock() discovery with generic fallback and ORP resolution by @thomhurst in thomhurst/TUnit#5327 ### Dependencies * chore(deps): update tunit to 1.24.0 by @thomhurst in thomhurst/TUnit#5315 * chore(deps): update aspire to 13.2.1 by @thomhurst in thomhurst/TUnit#5323 * chore(deps): update verify to 31.14.0 by @thomhurst in thomhurst/TUnit#5325 ## New Contributors * @erwinkramer made their first contribution in thomhurst/TUnit#5328 **Full Changelog**: thomhurst/TUnit@v1.24.0...v1.24.13 ## 1.24.0 <!-- Release notes generated using configuration in .github/release.yml at v1.24.0 --> ## What's Changed ### Other Changes * perf: optimize TUnit.Mocks hot paths by @thomhurst in thomhurst/TUnit#5304 * fix: resolve System.Memory version conflict on .NET Framework (net462) by @thomhurst in thomhurst/TUnit#5303 * fix: resolve CS0460/CS0122/CS0115 when mocking concrete classes from external assemblies by @thomhurst in thomhurst/TUnit#5310 * feat(mocks): parameterless Returns() and ReturnsAsync() for async methods by @thomhurst in thomhurst/TUnit#5309 * Fix typo in NUnit manual migration guide by @aa-ko in thomhurst/TUnit#5312 * refactor(mocks): unify Mock.Of<T>() and Mock.OfPartial<T>() into single API by @thomhurst in thomhurst/TUnit#5311 * refactor(mocks): clean up Mock API surface by @thomhurst in thomhurst/TUnit#5314 * refactor(mocks): remove generic/untyped overloads from public API by @thomhurst in thomhurst/TUnit#5313 ### Dependencies * chore(deps): update tunit to 1.23.7 by @thomhurst in thomhurst/TUnit#5305 * chore(deps): update dependency mockolate to 2.1.1 by @thomhurst in thomhurst/TUnit#5307 ## New Contributors * @aa-ko made their first contribution in thomhurst/TUnit#5312 **Full Changelog**: thomhurst/TUnit@v1.23.7...v1.24.0 Commits viewable in [compare view](thomhurst/TUnit@v1.23.7...v1.24.13). </details> [](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>
Summary
Returns()for void/Task/ValueTask methods: Enables sequential behavior chains like.Returns().Then.Throws<Exception>().Then.Returns()for void-returning methods, which previously had no way to express "return normally" as a step in a sequence.ReturnsAsync()for Task/Task<T>/ValueTask/ValueTask<T> methods: Allows returning pre-built Task instances directly (e.g. fromTaskCompletionSource) for controlled timing in mock tests, rather than always having the framework wrap values inTask.FromResult().RawReturnmarker type with thread-localRawReturnContextside-channel, following the establishedOutRefContextpattern.Changes
VoidReturnBehavior(singleton no-op behavior for parameterlessReturns())RawReturnBehavior/ComputedRawReturnBehavior(wrap values inRawReturnmarker)RawReturnmarker type +RawReturnContextthread-local contextReturnsAsync()overloads on generated wrapper classes for async methodsRawReturnContext.TryConsume()checks in async method implementationsMockEngineclearsRawReturnContextat dispatch entry and checks forRawReturnmarker in behavior resultsShouldGenerateTypedWrapper)Test plan
Void_Returns_Then_Throws_Then_Returns— parameterless Returns() chainingReturnsAsync_Task_With_TaskCompletionSource— Task<T> with TCSReturnsAsync_ValueTask_With_TaskCompletionSource— ValueTask<T> with TCSReturnsAsync_Void_Task_With_TaskCompletionSource— void Task with TCSReturnsAsync_Factory_Returns_Different_Tasks— factory overloadReturnsAsync_Then_Returns_Sequence— mixing ReturnsAsync and ReturnsReturnsAsync_Already_Completed_Task— pre-completed task