Skip to content

Latest commit

 

History

History
545 lines (404 loc) · 15.8 KB

File metadata and controls

545 lines (404 loc) · 15.8 KB

Delegate Stubs

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 { }

When to Use Delegate Stubs

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.

Basic Usage

Define a delegate type, apply [KnockOff<TDelegate>] to your test class, and use the generated stub.

Void Delegate with No Parameters

// Create stub, convert to delegate, invoke, and verify
var stub = new BasicVoidDelegateTest.Stubs.OnComplete();
OnComplete callback = stub;
callback();
stub.Interceptor.Verify();

Delegate with Return Value

// Default return value is null; LastArg tracks the argument
Assert.Null(result);
Assert.Equal("hello", stub.Interceptor.LastArg);

Delegate with Multiple Parameters

// Access arguments via named tuple
Assert.Equal("Alice", stub.Interceptor.LastArgs!.Value.name);
Assert.Equal(30, stub.Interceptor.LastArgs!.Value.age);

Configuring Callbacks

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.

Configuration Methods

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++)

Call for Void Delegates

// Configure side effects for void delegate
stub.Interceptor.Call(() => notified = true);

Return with Fixed Value

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.

Return with Computed Value

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.

Return with Multiple Parameters

// Configure with multiple parameters
stub.Interceptor.Call((name, age) => $"{name} is {age} years old");

Verification

Verify delegate invocations using stub.Interceptor.Verify() and Called constraints.

Basic Verification

// Verify() passes - delegate was called at least once
stub.Interceptor.Verify();

Verification with Called

// Verify with Times constraints
stub.Interceptor.Verify(Called.Exactly(3));
stub.Interceptor.Verify(Called.AtLeast(2));
stub.Interceptor.Verify(Called.AtMost(5));

Verifiable Pattern

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);

Tracking Invocations

Delegate stubs track every invocation, providing access to the last call's arguments.

Single Parameter Tracking

// LastArg captures the most recent argument
Assert.Equal("second", stub.Interceptor.LastArg);

Multiple Parameter Tracking

// LastArgs provides named tuple access
Assert.Equal("Bob", stub.Interceptor.LastArgs!.Value.name);
Assert.Equal(25, stub.Interceptor.LastArgs!.Value.age);

Call Count

// Verify invocation count using Times constraints
stub.Interceptor.Verify(Called.Exactly(3));

Open Generic Delegates

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 Delegates

// 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 Delegates

// 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);

Type Constraints Preserved

// ConstrainedFactory<T> requires T : new() - compiler enforces this
var productFactory = new OpenGenericDelegateTest.Stubs.ConstrainedFactory<Product>();
productFactory.Interceptor.Call(() => new Product { Id = 1, Name = "Widget" });

Reset Behavior

Use stub.Interceptor.Reset() to clear tracking state while preserving configuration.

Reset Clears Tracking

// 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 works

Sequences

Delegate stubs support the same sequence API as interface and class stubs.

Return Sequences

// 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

// Callback sequences
stub.Interceptor
    .Call((x) => x * 1)
    .ThenReturn((x) => x * 2)
    .ThenReturn((x) => x * 3);

ThenReturn

// ThenReturn for fixed values after callback
stub.Interceptor
    .Call((x) => x)
    .ThenReturn(99);

ThenDefault

// 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 Delegate Auto-Wrapping

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.

When Chains (Parameter Matching)

Delegate interceptors support conditional parameter matching via When(), identical to interface and class stubs. Requires at least one parameter.

Value Matching

// Match specific argument values
stub.Interceptor.When(1, 2).Return(100)
    .ThenWhen(3, 4).Return(200)
    .ThenCall((a, b) => a + b);  // terminal fallback

Predicate Matching

// 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");

Chained When

stub.Interceptor
    .When("one").Return("ONE")
    .ThenWhen("two").Return("TWO")
    .ThenWhen(s => s.StartsWith("x")).Return("X_PREFIX");

Void Delegate When Chains

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"));

ThenNone (Exhaust Matching)

// 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.

Strict Mode

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.NotConfigured

In 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.SequenceExhausted

Configuration Methods — Last One Wins

All configuration methods use direct replacement. Calling any configuration method replaces the previous configuration of the same kind:

  • Return(value) and Return(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.

Priority Resolution Order

When a delegate is invoked, KnockOff checks configurations in this priority order:

  1. When chains (highest) — parameter-specific matching
  2. Sequences -- Return().ThenReturn() / Call().ThenCall() sequence callbacks
  3. Return value -- Return(value) repeating constant
  4. Return callback -- Return(delegate) repeating callback
  5. Simplified callback -- Return(simplified) for async delegates
  6. Strict mode check — throws StubException.NotConfigured if strict
  7. Smart defaultdefault(T) for value types, null for reference types

Implicit Conversion

Delegate stubs implicitly convert to the delegate type, allowing seamless substitution.

Direct Assignment

// Implicit conversion - no cast required
Formatter format = stub;
var result = format("hello");

Method Parameters

// Pass stub directly to method expecting Formatter
var result = ProcessWithFormatter(stub);

Real-World Examples

Validation Rule Stub

// Configure validation: "admin" is taken, others are available
stub.Interceptor.Call((value) => value != "admin");

Factory Function Stub

// Configure factory to return test instance
stub.Interceptor.Call(() => testProduct);
Factory<Product> factory = stub;

Event Callback Stub

// Track received events
stub.Interceptor.Call((evt) => receivedEvent = evt);

Complete Example

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);

Next Steps


UPDATED: 2026-02-18