Skip to content

[TUnit.Mocks] .Returns() only accepts a synchronous factory — can't simulate a mocked async call that's still pending when a timeout/cancellation fires #6515

Description

@samtrion

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions