Async Patterns

February 18, 2026 · View on GitHub

KnockOff provides three configuration tiers for async methods (Task<T>, ValueTask<T>), each with increasing control. The first two tiers auto-wrap return values so you never write Task.FromResult for simple cases.

See also:


The Three-Tier Async API

For an async method like Task<string> GetDataAsync(int id):

TierAPIAcceptsAuto-wraps?
1Return(value)stringYes -- Task.FromResult(value)
2Return((id) => value)Func<int, string>Yes -- Task.FromResult(value)
3Return((id) => Task.FromResult(value))Func<int, Task<string>>No -- you provide the Task

All three tiers work identically across all 9 stub patterns (1–9), including delegate stubs.


Task Methods

Return(unwrappedValue) auto-wraps the value in Task.FromResult:

// Given: Task<string?> GetDataAsync(int id)
stub.GetDataAsync.Return("hello");

IDataService svc = stub;
var result = await svc.GetDataAsync(1); // "hello"

No Task.FromResult needed. This is the simplest syntax when the return value is constant.

Return(Func<..., T>) receives typed arguments and returns the unwrapped type. KnockOff auto-wraps the result:

// Callback returns string, not Task<string> — auto-wrapped
stub.GetDataAsync.Return((id) => $"Data-{id}");

IDataService svc = stub;
var result = await svc.GetDataAsync(42); // "Data-42"

Use this when the return value depends on the arguments but you don't need async behavior in the callback itself.

Tier 3: Full Delegate (For Async Callbacks)

Return(Func<..., Task<T>>) gives full control -- you construct the Task yourself:

// Callback returns Task<string?> directly
stub.GetDataAsync.Return((int id) => Task.FromResult<string?>($"Full-{id}"));

IDataService svc = stub;
var result = await svc.GetDataAsync(99); // "Full-99"

Use this when you need async/await inside the callback (e.g., simulating delays) or when returning faulted tasks.


Void Async Methods (Task Return)

For methods returning Task with no value, Call accepts an Action:

// Given: Task SaveDataAsync(string data)
string? savedData = null;
stub.SaveDataAsync.Call((data) => savedData = data);

IDataService svc = stub;
await svc.SaveDataAsync("important data");
// savedData == "important data"

The generated interceptor returns Task.CompletedTask automatically.


ValueTask Methods

The same three tiers apply to ValueTask<T>:

// Tier 1: Return — auto-wraps in new ValueTask<T>(value)
stub.GetCachedAsync.Return(cachedUser);

// Tier 2: Simplified callback — returns T, auto-wrapped
stub.GetCachedAsync.Return((id) => new User { Id = id });

// Tier 3: Full delegate — returns ValueTask<T> directly
stub.GetCachedAsync.Return((id) => new ValueTask<User?>(new User { Id = id }));

Delegate Stubs — Full Auto-Wrapping (Pattern 7)

Delegate stubs (Pattern 7) support the same three-tier async API as all other patterns. Async delegates like delegate Task<int> AsyncOperation(int x) get full auto-wrapping:

// Given: delegate Task<int> AsyncOperation(int x)

// Tier 1: Return takes the inner type — auto-wraps in Task.FromResult
stub.Interceptor.Return(42);

// Tier 2: Simplified callback — returns int, auto-wrapped
stub.Interceptor.Return((int x) => x * 2);

// Tier 3: Full delegate — returns Task<int> directly
stub.Interceptor.Return((int x) => Task.FromResult(x * 2));

See also: Delegate Stubs Guide


Sequences with Async Methods

Async methods support params-style sequences with auto-wrapping:

// Return multiple values — each auto-wrapped in Task.FromResult
stub.GetDataAsync.Return("first", "second", "third");
// Call 1: "first", Call 2: "second", Call 3+: "third" (repeats last)

// Callback sequences also work
stub.GetDataAsync
    .Return((id) => "initial")
    .ThenReturn((id) => "updated");

Simulating Delays

Use async lambdas with the Tier 3 API to simulate asynchronous delays:

stub.GetDataAsync.Return(async (id) =>
{
    await Task.Delay(50);
    return $"Delayed-{id}";
});

Simulating Failures

Using Task.FromException

Return a faulted task using Task.FromException<T>:

stub.GetDataAsync.Return((id) =>
    Task.FromException<string?>(new NotFoundException($"Item {id} not found")));

Throwing Directly

Throw exceptions directly in the callback. The exception is thrown when the method is awaited:

stub.GetDataAsync.Return((int id) =>
    throw new NotFoundException($"Item {id} not found"));

When throwing directly in a simplified callback (Tier 2), you may need to specify the parameter type explicitly to disambiguate overloads.


Choosing Your Tier

ScenarioRecommended TierExample
Constant return valueTier 1: Returnstub.Method.Return("value")
Value depends on argsTier 2: Simplified callbackstub.Method.Return((id) => ...)
Need async/await in callbackTier 3: Full delegatestub.Method.Return(async (id) => ...)
Simulating failuresTier 3: Full delegatestub.Method.Return((id) => Task.FromException<T>(...))
Delegate stubsSame 3 tiersstub.Interceptor.Return(42) (auto-wraps)

Key Takeaways

  • Three tiers for async methods: Return(T), Return(Func<..., T>), Return(Func<..., Task<T>>)
  • Tiers 1 and 2 auto-wrap -- you work with the unwrapped type, KnockOff handles Task.FromResult
  • Tier 3 gives full control -- use for async lambdas, delays, and faulted tasks
  • Void async methods use Call(Action<...>) -- Task.CompletedTask is returned automatically
  • ValueTask follows the same three tiers with ValueTask wrapping
  • All 9 patterns (including Pattern 7 delegates) support identical async APIs
  • All interceptor features (verification, argument capture, sequences, When chains) work with async methods

UPDATED: 2026-02-18