Summary
For a member returning Task<T> / ValueTask<T>, .Returns(...) only accepts a value or a synchronous Func<T>, which gets auto-wrapped into an already-completed task. There's no overload that accepts an actually-asynchronous factory (Func<Task<T>> / Func<ValueTask<T>>), so a mock can never hand back a task that is still incomplete when the caller races it against something else (e.g. a timeout).
Context
Testing a health-check/timeout code path that races a dependency call against a CancellationToken/timeout requires the mocked call to genuinely still be in flight when the timeout fires — not complete instantly and not complete synchronously-but-wrapped. NSubstitute supports this via an async lambda passed to .Returns():
using NSubstitute;
var mockConnection = Substitute.For<IConnection>();
_ = mockConnection
.CreateChannelAsync(Arg.Any<CreateChannelOptions>(), Arg.Any<CancellationToken>())
.Returns(async _ =>
{
await Task.Delay(50); // Delay to force the caller's timeout to win the race
return mockChannel;
});
Here, CreateChannelAsync's returned task is a real task that doesn't complete until the 50ms delay elapses, so a test asserting "caller times out before the dependency responds" is a genuine race, not a fake one.
What happens with TUnit.Mocks
using TUnit.Mocks;
var mockConnection = IConnection.Mock();
_ = mockConnection.CreateChannelAsync(Any(), Any()).Returns(() =>
{
Thread.Sleep(50); // has to become a BLOCKING sleep, not an async delay
return mockChannel;
});
.Returns() here takes a synchronous Func<T> and wraps whatever it returns into an already-completed Task<T>/ValueTask<T> — evaluated (including any Thread.Sleep) before the task is handed back, not concurrently with the caller's own timeout logic. This means:
- The delay has to be simulated with a blocking
Thread.Sleep, not await Task.Delay.
- The "task in flight" is never actually true — by the time the caller receives the task, the mocked work has already fully executed and the task is already completed.
- A test asserting a genuine timeout race (caller's
CancellationToken fires before the dependency's task completes) cannot be expressed at all — the mocked task is either already-done-fast or the whole test thread is blocked for the sleep duration with no real concurrency.
This is documented as a known scoped exception in dailydevops/healthchecks PR #2051 (one RabbitMQ test kept on NSubstitute specifically for this reason), and we hit the same shape of limitation independently while migrating dailydevops/http.correlation.
Minimal repro shape
public interface IClient
{
Task<int> GetValueAsync(CancellationToken ct);
}
public class Consumer
{
public async Task<int> GetWithTimeoutAsync(IClient client, TimeSpan timeout)
{
using var cts = new CancellationTokenSource(timeout);
var task = client.GetValueAsync(cts.Token);
var completed = await Task.WhenAny(task, Task.Delay(Timeout.Infinite, cts.Token));
if (completed != task)
{
throw new TimeoutException();
}
return await task;
}
}
// Test: assert GetWithTimeoutAsync throws TimeoutException when the client is slower than `timeout`.
// With NSubstitute:
var client = Substitute.For<IClient>();
client.GetValueAsync(Arg.Any<CancellationToken>()).Returns(async _ =>
{
await Task.Delay(200);
return 42;
});
// consumer.GetWithTimeoutAsync(client, TimeSpan.FromMilliseconds(10)) genuinely races and times out.
// With TUnit.Mocks there is no equivalent - Returns() only takes a sync Func<T>,
// so the 200ms "delay" can only be a blocking Thread.Sleep evaluated before the
// task is returned, which defeats the purpose of the race entirely.
Ask
Would an additional .Returns() overload accepting Func<Task<T>> / Func<ValueTask<T>> directly (i.e. "here is the actual asynchronous operation to run, don't wrap its result, just return the task/valuetask as-is") be feasible? That would let the caller's own timeout/cancellation logic race against a task that's genuinely still pending.
Versions
Summary
For a member returning
Task<T>/ValueTask<T>,.Returns(...)only accepts a value or a synchronousFunc<T>, which gets auto-wrapped into an already-completed task. There's no overload that accepts an actually-asynchronous factory (Func<Task<T>>/Func<ValueTask<T>>), so a mock can never hand back a task that is still incomplete when the caller races it against something else (e.g. a timeout).Context
Testing a health-check/timeout code path that races a dependency call against a
CancellationToken/timeout requires the mocked call to genuinely still be in flight when the timeout fires — not complete instantly and not complete synchronously-but-wrapped. NSubstitute supports this via an async lambda passed to.Returns():Here,
CreateChannelAsync's returned task is a real task that doesn't complete until the 50ms delay elapses, so a test asserting "caller times out before the dependency responds" is a genuine race, not a fake one.What happens with TUnit.Mocks
.Returns()here takes a synchronousFunc<T>and wraps whatever it returns into an already-completedTask<T>/ValueTask<T>— evaluated (including anyThread.Sleep) before the task is handed back, not concurrently with the caller's own timeout logic. This means:Thread.Sleep, notawait Task.Delay.CancellationTokenfires before the dependency's task completes) cannot be expressed at all — the mocked task is either already-done-fast or the whole test thread is blocked for the sleep duration with no real concurrency.This is documented as a known scoped exception in
dailydevops/healthchecksPR #2051 (one RabbitMQ test kept on NSubstitute specifically for this reason), and we hit the same shape of limitation independently while migratingdailydevops/http.correlation.Minimal repro shape
Ask
Would an additional
.Returns()overload acceptingFunc<Task<T>>/Func<ValueTask<T>>directly (i.e. "here is the actual asynchronous operation to run, don't wrap its result, just return the task/valuetask as-is") be feasible? That would let the caller's own timeout/cancellation logic race against a task that's genuinely still pending.Versions
TUnit.Mocks1.62.0dailydevops/healthchecksPR chore(deps): update tunit to 0.17.14 #2051 (scoped exception, same root cause),dailydevops/http.correlationFeature: Injectable data via required properties & object initializer #723.