Delegate stubs allow you to test code that accepts delegates as parameters. Use the inline pattern [KnockOff<TDelegate>] to generate a delegate stub that tracks invocations and configures behavior.
Important: Only named delegate types are supported. Func<> and Action<> cannot be stubbed directly — define a named delegate instead:
// Does NOT work:
// [KnockOff<Func<int, int, int>>] // Not supported
// Define a named delegate instead:
public delegate int NamedCalculation(int a, int b);
[KnockOff<NamedCalculation>] // Works!
public partial class NamedDelegateExample { }Use delegate stubs when testing:
- Validation rules - Predicate callbacks that validate business logic
- Factory functions - Delegates that construct objects on demand
- Event handlers - Callbacks triggered by domain events
- Filters and transformations -
Func<T, TResult>passed to query or processing logic
Delegate stubs track every invocation, capture arguments, and allow you to configure return values through callbacks.
Define a delegate type, apply [KnockOff<TDelegate>] to your test class, and use the generated stub.
// Create stub, convert to delegate, invoke, and verify
var stub = new BasicVoidDelegateTest.Stubs.OnComplete();
OnComplete callback = stub;
callback();
stub.Interceptor.Verify();// Default return value is null; LastArg tracks the argument
Assert.Null(result);
Assert.Equal("hello", stub.Interceptor.LastArg);// Access arguments via named tuple
Assert.Equal("Alice", stub.Interceptor.LastArgs!.Value.name);
Assert.Equal(30, stub.Interceptor.LastArgs!.Value.age);Use stub.Interceptor.Return(...) (non-void delegates) or stub.Interceptor.Call(...) (void delegates) to configure custom behavior when the delegate is invoked.
Important: Each call to Return/Call replaces the previous configuration. The most recent configuration wins.
Delegates with return values support two configuration methods:
| Method | Signature | Use Case | Example |
|---|---|---|---|
| Return | Return(TReturn value) |
Return fixed value regardless of input | stub.Interceptor.Return("SUCCESS") |
| Return | Return(Func<...> callback) |
Compute return value from input | stub.Interceptor.Return((x) => x * 2) |
Void delegates (Action, Action<T>) support only the callback method:
| Overload | Signature | Use Case | Example |
|---|---|---|---|
| Call | Call(Action callback) |
Execute side effects | stub.Interceptor.Call(() => counter++) |
// Configure side effects for void delegate
stub.Interceptor.Call(() => notified = true);For delegates that return values, configure a fixed return value using Return(). The value is returned regardless of input arguments.
// Return() - pass the return value directly (simpler syntax)
stub.Interceptor.Return("FORMATTED");Use Return(value) when:
- Return value is constant across all invocations
- You need simpler test setup
- Input arguments don't affect the result
Signature: Return(TReturn value) where TReturn is the delegate's return type.
Use the callback overload to compute the return value based on input arguments. The callback receives the same parameters as the delegate and returns the result.
// Return() - compute return value based on input
stub.Interceptor.Call((input) => input.ToUpperInvariant());Use the callback overload when:
- Return value depends on input arguments
- You need conditional logic or computation
- You need to capture or transform input
Signature: Return(Func<TArg1, ..., TReturn> callback) matching the delegate signature.
// Configure with multiple parameters
stub.Interceptor.Call((name, age) => $"{name} is {age} years old");Verify delegate invocations using stub.Interceptor.Verify() and Called constraints.
// Verify() passes - delegate was called at least once
stub.Interceptor.Verify();// Verify with Times constraints
stub.Interceptor.Verify(Called.Exactly(3));
stub.Interceptor.Verify(Called.AtLeast(2));
stub.Interceptor.Verify(Called.AtMost(5));Delegate stubs support .Verifiable() chaining on Return() and Call(), just like interface and class stubs:
// Mark for verification with Verifiable() chaining
stub.Interceptor.Call((x) => x * 2).Verifiable();
stub.Interceptor.Verify(Called.Never); // Not called yet
Transform transform = stub;
var result = transform(21);
// Verify the delegate was called
stub.Interceptor.Verify(Called.Once);Delegate stubs track every invocation, providing access to the last call's arguments.
// LastArg captures the most recent argument
Assert.Equal("second", stub.Interceptor.LastArg);// LastArgs provides named tuple access
Assert.Equal("Bob", stub.Interceptor.LastArgs!.Value.name);
Assert.Equal(25, stub.Interceptor.LastArgs!.Value.age);// Verify invocation count using Times constraints
stub.Interceptor.Verify(Called.Exactly(3));KnockOff supports closed generic delegates using standard generic attribute syntax and open generic delegates using typeof().
NOTE: Open generic delegate stubs use the Open Generic pattern (
[KnockOff(typeof(Delegate<>))]). For details on when to choose this pattern versus defining a Generic Standalone stub, see Stub Patterns - Open Generic.
// Closed generic: type arguments specified at stub definition
var stub = new DelegateStubTests.Stubs.Factory();
stub.Interceptor.Call(() => "generated value");
Factory<string> factory = stub;// Open generic: create stub with any type argument
var stringFactory = new OpenGenericDelegateTest.Stubs.Factory<string>();
stringFactory.Interceptor.Call(() => "hello");
var intFactory = new OpenGenericDelegateTest.Stubs.Factory<int>();
intFactory.Interceptor.Call(() => 42);// ConstrainedFactory<T> requires T : new() - compiler enforces this
var productFactory = new OpenGenericDelegateTest.Stubs.ConstrainedFactory<Product>();
productFactory.Interceptor.Call(() => new Product { Id = 1, Name = "Widget" });Use stub.Interceptor.Reset() to clear tracking state while preserving configuration.
// Reset clears tracking state but preserves configuration
stub.Interceptor.Reset();
stub.Interceptor.Verify(Called.Never);
Assert.Null(stub.Interceptor.LastArg);
Assert.Equal("TEST", format("test")); // Return still worksDelegate stubs support the same sequence API as interface and class stubs.
// Return different values on successive calls
stub.Interceptor.Return(10, 20, 30);
// Call 1: 10, Call 2: 20, Call 3+: 30 (repeats last)// Callback sequences
stub.Interceptor
.Call((x) => x * 1)
.ThenReturn((x) => x * 2)
.ThenReturn((x) => x * 3);// ThenReturn for fixed values after callback
stub.Interceptor
.Call((x) => x)
.ThenReturn(99);// ThenDefault: return default(T) after exhaustion instead of repeating
stub.Interceptor
.Call((a, b) => 100)
.ThenReturn((a, b) => 200)
.ThenDefault();
// Call 1: 100, Call 2: 200, Call 3+: 0 (default(int))Async delegates (e.g., delegate Task<int> AsyncOp(int x)) support the same three-tier auto-wrapping as interface and class stubs:
// Tier 1: Returns takes inner type - auto-wraps in Task.FromResult
stub.Interceptor.Return(42);See Async Patterns for more details.
Delegate interceptors support conditional parameter matching via When(), identical to interface and class stubs. Requires at least one parameter.
// Match specific argument values
stub.Interceptor.When(1, 2).Return(100)
.ThenWhen(3, 4).Return(200)
.ThenCall((a, b) => a + b); // terminal fallback// Match via predicate
stub.Interceptor.When((int a, int b) => a > 10).Return(999);// Single-parameter delegate
stub.Interceptor.When(s => s.Length > 5).Return("LONG");stub.Interceptor
.When("one").Return("ONE")
.ThenWhen("two").Return("TWO")
.ThenWhen(s => s.StartsWith("x")).Return("X_PREFIX");Void delegates use .Call() instead of .Return():
stub.Interceptor
.When(1, 2).Call((a, b) => calls.Add("first"))
.ThenWhen(3, 4).Call((a, b) => calls.Add("second"));// After "one" is matched, subsequent calls fall through to default behavior
stub.Interceptor.When("one").Return("ONE").ThenNone();See Parameter Matching Guide for more details.
Delegate stubs have a Strict property. When true, unconfigured invocations throw StubException.NotConfigured instead of returning default(T).
var stub = new DelegateStubTests.Stubs.Calculate();
stub.Strict = true;
Calculate calc = stub;
Assert.Throws<StubException>(() => calc(1, 2)); // Throws StubException.NotConfiguredIn strict mode, exhausted sequences throw StubException.SequenceExhausted:
stub.Strict = true;
stub.Interceptor.Return(10, 20);
Calculate op = stub;
Assert.Equal(10, op(0, 0)); // first value
Assert.Equal(20, op(0, 0)); // second value
Assert.Throws<StubException>(() => op(0, 0)); // Throws StubException.SequenceExhaustedAll configuration methods use direct replacement. Calling any configuration method replaces the previous configuration of the same kind:
Return(value)andReturn(callback)replace each other- Multiple
Call(callback)calls — last wins - Multiple
When()calls — last wins (replaces previous When chain)
Within a When chain, .ThenWhen() accumulates matchers. But calling .When() again as a new entry point replaces the entire chain.
Known bug: .When() currently accumulates like .ThenWhen() instead of replacing. See docs/todos/when-entry-point-should-clear-chain.md.
stub.Interceptor.Return(42);
stub.Interceptor.Call((a, b) => a + b); // Clears Return(42)Note:
Return()is for delegates with return values.Call()is for void delegates. A delegate interceptor only has one or the other — never both.
When a delegate is invoked, KnockOff checks configurations in this priority order:
- When chains (highest) — parameter-specific matching
- Sequences --
Return().ThenReturn()/Call().ThenCall()sequence callbacks - Return value --
Return(value)repeating constant - Return callback --
Return(delegate)repeating callback - Simplified callback --
Return(simplified)for async delegates - Strict mode check — throws
StubException.NotConfiguredif strict - Smart default —
default(T)for value types,nullfor reference types
Delegate stubs implicitly convert to the delegate type, allowing seamless substitution.
// Implicit conversion - no cast required
Formatter format = stub;
var result = format("hello");// Pass stub directly to method expecting Formatter
var result = ProcessWithFormatter(stub);// Configure validation: "admin" is taken, others are available
stub.Interceptor.Call((value) => value != "admin");// Configure factory to return test instance
stub.Interceptor.Call(() => testProduct);
Factory<Product> factory = stub;// Track received events
stub.Interceptor.Call((evt) => receivedEvent = evt);This example demonstrates delegate stubs in a realistic validation scenario.
// Configure format rule: must be at least 3 characters
formatStub.Interceptor.Call((value) => value.Length >= 3);
// Configure uniqueness rule: "admin" and "root" are taken
uniqueStub.Interceptor.Call((value) => value != "admin" && value != "root");
// Create validator with stubbed rules
var validator = new UsernameValidator(uniqueStub, formatStub);- Stub Patterns - Learn about all nine patterns including Inline Delegate
- Methods Guide - Configure method behavior with Return/Call
- Parameter Matching - When chains and conditional behavior
- Async Patterns - Async auto-wrapping details
- Verification Guide - Verify delegate invocations
- Interceptor API Reference - Complete API documentation
UPDATED: 2026-02-18