To ensure the long-term sustainability of this project, users of this package who generate revenue must pay an Open Source Maintenance Fee. While the source code is freely available under the terms of the License, this package and other aspects of the project require adherence to the Maintenance Fee.
To pay the Maintenance Fee, become a Sponsor at the proper OSMF tier. A single fee covers all of Devlooped packages.
A modern interception library that runs everywhere, even where run-time code generation (Reflection.Emit) is forbidden or limitted (i.e. physical iOS devices and game consoles), through compile-time code generation.
The Stunts name was inspired by the Test Double naming in the mock objects literature, which in turn comes from the Stunt Double concept from film making. This project allows your objects to pull arbitrary stunts based on your instructions/choreography 😉.
Stunts essentially implements the proxy pattern and adds the capability of configuring those proxies via code using what we call a behavior pipeline.
NOTE: Stunts provides a fairly low-level API with just the essential building blocks on top of which higher-level APIs can be built, such as the upcoming Moq vNext API.
var stunt = Stunt.For<ICalculator>();
ICalculator calc = stunt.ToObject();
stunt.AddBehavior((invocation, next) => ...);Stunt.Of<T> returns the stunt directly, and Stunt.Get(stunt) gets a StuntReference<T> for an existing one, so behaviors can be added after the fact:
ICalculator calc = Stunt.Of<ICalculator>();
Stunt.Get(calc).AddBehavior((invocation, next) => ...);NOTE:
StuntReference<T>converts implicitly toTfor classes and delegates. C# does not allow user-defined conversions to interfaces, soToObject()is always available.
AddBehavior/InsertBehavior are extension methods on IStunt (which StuntReference<T> implements) and allow granular control of the stunt's behavior pipeline, which is basically a chain of responsibility that invokes all configured behaviors that apply to the current invocation. Individual behaviors can determine whether to short-circuit the call or call the next behavior in the chain.
Behaviors can also dynamically determine whether they apply to a given invocation by providing the optional appliesTo argument. In addition to the delegate-based overloads (called anonymous behaviors), you can also create behaviors by implementing the IStuntBehavior interface:
public interface IStuntBehavior
{
bool AppliesTo(IMethodInvocation invocation);
IMethodReturn Execute(IMethodInvocation invocation, ExecuteHandler next);
}Some commonly used behaviors that are generally useful are provided in the library and can be added to stunts as needed:
-
DefaultValueBehavior: sets default values for method return and out arguments. In addition to the built-in supported default values, additional default value factories can be registered for any type. -
DefaultEqualityBehavior: implements the Object.Equals and Object.GetHashCode members just like System.Object implements them. -
RecordingBehavior: simple behavior that keeps track of all invocations, for troubleshooting or reporting.
When you need the same behaviors on multiple stunts, Stunt.Builder() returns a StuntBuilder
that collects behaviors (with the very same AddBehavior/InsertBehavior extension methods) and
applies them to every stunt it builds, with the same Build<T> overloads as Stunt.Of<T>:
var builder = Stunt.Builder()
.AddBehavior(new RecordingBehavior())
.AddBehavior(new DefaultValueBehavior());
ICalculator calculator = builder.Build<ICalculator>();
IStore store = builder.Build<IStore>();Since the behaviors are in place before the stunt is instantiated (via an ambient
BehaviorPipelineFactory), they also intercept virtual members invoked from base class
constructors, which isn't possible when behaviors are added to an already created stunt:
public class Greeter
{
public Greeter() => Seen = Name();
public string Seen { get; }
public virtual string Name() => "base";
}
Greeter greeter = Stunt.Builder()
.AddBehavior((invocation, next) => invocation.MethodBase.Name == nameof(Greeter.Name)
? invocation.CreateValueReturn("proxy")
: next(invocation, next))
.Build<Greeter>();
// greeter.Seen == "proxy"Each Build call takes a snapshot of the behaviors configured at that point, so behaviors added
to the builder afterwards don't affect the stunts already built. Behavior instances themselves are
shared, so a single RecordingBehavior records the invocations of all stunts from that builder.
If you want to centrally configure all your stunts, the easiest way is to simply provide your own factory method (i.e. Stub.Of<T>), which in turn calls the Stunt.Of<T> provided. For example:
public static class Stub
{
[StuntGenerator]
public static T Of<T>() => Stunt.For<T>()
.AddBehavior(new RecordingBehavior())
.AddBehavior(new DefaultEqualityBehavior())
.AddBehavior(new DefaultValueBehavior())
.ToObject();
}The [StuntGenerator] attribute is required if you want to leverage the built-in compile-time code generation, since that signals to the source generator that calls to your API end up creating a stunt at run-time and therefore a generated type will be needed for it during compile-time. You can actually explore how this very same behavior is implemented in the built-in Stunts API, which is provided as content files (Stunt.cs and Stunt.vb):
[StuntGenerator]
public static T Of<T>(params object[] constructorArgs) => Create<T>(constructorArgs);
[StuntGenerator]
public static T Of<T, T1>(params object[] constructorArgs) => Create<T>(constructorArgs, typeof(T1));As you can see, the Stunts API itself uses the same extensibility mechanism that your own custom factory methods can use.
By default, Stunts generates proxies at compile-time (powered by Roslyn source generators). Whenever compile-time stunts are
not supported (or unwanted), install the Stunts.DynamicProxy package, which switches the project to run-time proxies based on Castle.Core:
<ItemGroup>
<PackageReference Include="Stunts.DynamicProxy" Version="..." />
</ItemGroup>The package sets EnableCompileTimeStunts=false for you. Projects that can't use compile-time stunts and don't reference Stunts.DynamicProxy get a build warning (ST011).
NOTE: even though generated proxies are the main usage for Stunts, the API was designed so that you can also consume the behavior pipeline easily from hand-coded proxies too.
The examples below use ICalculator from the samples, which declares Add(int x, int y). Each snippet starts with a fresh stunt.
An anonymous behavior can short-circuit a call. The appliesTo predicate limits it to the two-argument Add overload:
var calc = Stunt.For<ICalculator>().AddBehavior(
(call, _) => call.CreateValueReturn(42),
call => call.MethodBase.Name == nameof(ICalculator.Add) && call.Arguments.Count == 2)
.ToObject();
calc.Add(2, 3); // 42Arguments are available by name (or index), so a behavior can use the values passed by the caller:
var calc = Stunt.For<ICalculator>().AddBehavior(
(call, _) => call.CreateValueReturn(call.Arguments.Get<int>("x") + call.Arguments.Get<int>("y")),
call => call.MethodBase.Name == nameof(ICalculator.Add) && call.Arguments.Count == 2)
.ToObject();
calc.Add(2, 3); // 5Behaviors run in order. Put recording first to capture calls and results, and a default-value behavior last to handle calls not matched by the Add behavior:
var recorder = new RecordingBehavior();
var calc = Stunt.For<ICalculator>()
.AddBehavior(recorder)
.AddBehavior((call, _) => call.CreateValueReturn(5),
call => call.MethodBase.Name == nameof(ICalculator.Add) && call.Arguments.Count == 2)
.AddBehavior(new DefaultValueBehavior())
.ToObject();
calc.Add(2, 3); // 5
var calls = recorder.Invocations.Count; // 1Register a factory when the built-in defaults are not suitable. Here, each call to a delegate stunt returns a greeting:
var defaults = new DefaultValueProvider();
defaults.Register(() => "Hello!");
var greet = Stunt.For<Func<string>>().AddBehavior(new DefaultValueBehavior(defaults)).ToObject();
greet(); // "Hello!"Pass a delegate implementation to Stunt.For and call next to forward to it. Behaviors can change arguments before forwarding:
var add = Stunt.For<Func<int, int, int>>((x, y) => x + y).AddBehavior((call, next) =>
{
call.Arguments.Set(0, 10);
return next(call, next);
}).ToObject();
add(1, 2); // 12There is nothing more frustrating than a proxy/stunt you have carefully configured that doesn't behave the way you expect it to. In order to make this a less frustrating experience, Stunts is carefully optimized for debugger display and inspection, so that it's clear what behaviors are configured, and invocations and results are displayed clearly and concisely. Here's the debugging display of the RecordingBehavior that just keeps track of invocations and their return values for example:
And here's the invocation debugger display from an anonymous behavior:
The samples folder in the repository contains a few interesting examples of how Stunts can be used to implement some fancy use cases. For example:
-
Forwarding calls to matching interface methods/properties (by signature) to a static class. The example uses this to wrap calls to System.Console via an IConsole interface.
-
Forwarding calls to a target object using the DLR (that backs the dynamic keyword in C#) API for high-performance late binding.
-
Custom
Stub.Of<T>factory that creates stunts that have common behaviors configured automatically. -
Custom stunt factory method that adds an int return value randomizer.
-
Configuring the built-in DefaultValueBehavior so that every time a string property is retrieved, it gets a random lorem ipsum value.
-
Logging all calls to a stunt to the Xunit output helper.


