-
Notifications
You must be signed in to change notification settings - Fork 26
How Smocks works
The first step in the process is to identify the properties or methods to mock. This is done by disassembling the method provided to Smock.Run
to CIL instructions and finding all calls to `ISmocksContext.Setup(() => ...).
The argument to ISmocksContext.Setup
is an expression tree that contains the method or property that is the target of the setup. When one calls ISmocksContext.Setup
, the compiler emits instructions to generate the expression tree that represents the expression provided to Setup
. When Smocks finds a call to ISmocksContext.Setup
, it decompiles these instructions to reconstruct the expression tree.
Consider the following code:
Smock.Run(context =>
{
context.Setup(() => DateTime.Now).Returns(new DateTime(2000, 1, 1));
Console.WriteLine(DateTime.Now);
}
Performing the setup extraction on this code yields an expression tree for () => DateTime.Now
, without executing the Console.Writeline()
call. The expression trees obtained in this step, can then be used to identify all properties or methods that are the target of a Setup
call.
Smocks works by replacing all calls to targeted properties and methods with a call to an interceptor method. To do this, it generates modified replacement assemblies that it loads instead of the original assemblies. However, once an assembly has been loaded in an AppDomain
in .NET, it cannot be unloaded or reloaded. This makes it impossible to modify an assembly that is already loaded. When the user calls Smock.Run(context => ...)
, the assembly containing the lambda to run, is the same assembly that contains the currently executing method. This means that at least one of the assemblies that Smocks should rewrite, is already loaded and cannot be changed.
To resolve this problem, Smocks starts a new AppDomain
that does not have any assemblies loaded yet. It then installs an AppDomain.AssemblyResolve
event handler and executes the lambda provided to Smock.Run
. The AssemblyResolve
event will be triggered for every assembly referenced by the executing lambda. The AssemblyResolve
handler will then rewrite the referenced assemblies and load these assemblies instead of the original ones.
As stated earlier, Smocks works by replacing all calls to properties or methods that have an associated Setup(() => ...)
with a call to an interceptor method. Consider the following example:
Smock.Run(context =>
{
context.Setup(() => string.Format("1 + 1 = {0}", 2)).Returns("1 + 1 = 3");
Console.WriteLine(string.Format("1 + 1 = {0}", 2));
});
Smocks first extracts the Setup
expression tree () => string.Format("1 + 1 = {0}", 2)
to find that the method string.Format(string, object)
is targeted. It then notices there's a call to this method in the Console.WriteLine()
expression and replaces it with a call to a static interceptor method. After rewriting, the method roughly looks like this:
context.Setup(() => string.Format("1 + 1 = {0}", 2)).Returns("1 + 1 = 3");
// Get a MethodBase representing the target method.
MethodBase method = typeof(string).GetMethod("Format",
new [] { typeof(string), typeof(object)});
// Store the arguments of the method call.
object[] arguments = new object[2];
arguments[0] = "1 + 1 = {0}";
arguments[1] = 2;
// Intercept the method call and get a replacement return value.
string replacementReturnValue = Interceptor.Intercept<string>(arguments, method);
Console.WriteLine(replacementReturnValue);
The static Interceptor.Intercept
method knows the setups configured for the targeted method. It checks if any setup matches the provided arguments. If there's a matching setup, any configured behaviour (returning a constant value, throwing an exception, etc.) for the setup will be applied by the interceptor. The interceptor also tracks all invocations or targeted methods, so that calls to Verify
can confirm whether or not a configured setup was matched.
Smocks runs rewritten assemblies in a separate AppDomain
. Dealing with AppDomain
s can be rather tricky. AppDomain
s are fully separated worlds that don't share data, like separate processes. Microsoft has done a decent job at providing means of making communication between these separated worlds fairly easy, but there's a lot of remoting and serialization going on under the hood. This means that any data you want to pass between AppDomain
s must be serializable. This is the cause of an interesting problem. Consider the following example:
AppDomain domain = AppDomain.CreateDomain("Test");
string message = "Hello, world";
domain.DoCallBack(() =>
{
Console.WriteLine(message);
});
Here we invoke the DoCallBack
method, which runs a method in the targeted AppDomain
, with a lambda that captures the variable message
. Under the hood, C# generates a closure class containing a method for the body of the lambda and a field for the captured message
variable. It then asks the AppDomain
to execute the method on the closure class, serializing the closure class in the process. However, since generated closures are not serializable, an exception will be thrown:
Additional information: Type 'AppDomainSample.Program+<>c__DisplayClass1' in assembly 'AppDomainSample, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' is not marked as serializable.
Even though a string
is serializable, the class generated to wrap the string
is not, resulting in this error.
Smocks resolves this problem by handling the serialization explicitly. Any Delegate
passed to Smocks.Run
is decomposed into the Method to run and the Target instance of the method. The target is then serialized to a structure that is serializable, such as a string. The serialized target and the method (which is serializable by default) are then transferred to the other AppDomain, where the original Delegate
is reconstructed by deserializing the target and using it as the target for the method.
After invocation of the reconstructed Delegate
, this process can be reversed to ensure any changes made to the deserialized target of the Delegate
are transferred back to the original target: the target is serialized again and transferred back to the calling AppDomain
. There, the target is deserialized onto the original target of the Delegate
. This mechanism allows Smocks to support scenarios such as:
DateTime now = default(DateTime);
Smock.Run(context =>
{
context.Setup(() => DateTime.Now).Returns(new DateTime(2000, 1, 1));
now = DateTime.Now;
});
// Outputs: "2000"
Console.WriteLine(now.Year);
It should be noted however, that this mechanism can only transfer variables that are serializable or inherit from MarshalByRefObject
. Capturing variables in a Smocks.Run
scope should therefore be used with caution.