Rocks
Rocks copied to clipboard
Add a generic method for automatically instantiating Systems Under Test
Is your feature request related to a problem? Please describe.
I find that Rocks tends to cause a lot of boilerplate when setting up SUTs, especially for concrete objects that have a large dependency graph. The boilerplate makes reading and maintaining tests difficult.
Describe the solution you'd like
This request is to add a means for Rocks to provide a generic way to instantiate a SUT that provides mocks with default behavior unless otherwise specified.
For example, given this code:
public interface IFoo
{
public bool Foo();
}
public interface IBar
{
public bool Bar();
}
public interface IBaz
{
public bool Baz();
}
public class MyClass(IFoo foo, IBar bar, IBaz baz)
{
public void DoSomething()
{
if(foo.Foo())
{
bar.Bar();
}
else
{
baz.Baz();
}
}
}
When I want to write a test for when foo.Foo() is true, I shouldn't need to define a mock for baz.Baz() since its not in the execution path of the test in question.
Obviously, in order for this to work, Rocks will need to have knowledge of IFoo, IBar, and IBaz. Since Rocks uses code generation, it should be possible as part of that generation to have Rocks build up a list of known expectations. In the example, this is hand waved by the RocksKnownExpectations.GetRequiredService(explicitImpl); call. How exactly that method works could be accomplished a couple ways (i.e. IServiceCollection/IServiceProvider or something similar).
We could then add an extension method to Rocks that looks something like this (this is AI generated pseudo code so take it with a grain of salt, its just to convey the broad strokes):
public static T Instantiate<T, E>(IList<E> explicitImplementations) where T : class where E: Expectations
{
// Get the constructor with the most parameters from type T
var constructors = typeof(T).GetConstructors();
var constructor = constructors.OrderByDescending(c => c.GetParameters().Length).First();
// Get the constructor parameters (dependencies)
var constructorParameters = constructor.GetParameters();
// Prepare a list to hold the actual instances of the dependencies
var parameterInstances = new List<object>();
foreach (var parameter in constructorParameters)
{
// Check if an explicit implementation is provided for this parameter
var explicitImpl = explicitImplementations.FirstOrDefault(x => parameter.ParameterType.IsAssignableFrom(x.GetType()));
if (explicitImpl != null)
{
parameterInstances.Add(explicitImpl);
}
else
{
// If no explicit implementation is provided, create a new instance
var dependencyInstance = RocksKnownExpectations.GetRequiredService(explicitImpl);
parameterInstances.Add(dependencyInstance);
}
}
// Create an instance of T using the resolved dependencies
return (T)constructor.Invoke(parameterInstances.ToArray());
}
Our tests would then look something like this:
[Test]
public void DoSomethingTest()
{
var fooMock = new IFooCreateExpectations();
fooMock
.Methods
.Foo()
.ReturnValue(true);
var barMock = new IBarCreateExpectations();
barMock
.Methods
.Bar();
var underTest = Rocks.Instantiate<MyClass>(new List(){fooMock, barMock});
underTest.DoSomething();
underTest.Verify();
}
Note two important things in that example test.
First, there is no IBazExpectations instantiated. Since its not expected to be called in this execution path we do not need to explicitly instantiate one.
Second, there is only one Verify() call. That solves another common issue we run into with Rocks: folks forgetting to add a Verify() call to each of their expectations. That might be difficult to do directly against T, but should be fairly straight forward if the Instantiate<T> method were to return a tuple where the second output param would be some sort of verification object, i.e.:
public static (T, Verification) Instantiate<T>(IList<E> explicitImplementations) where T : class where E: Expectations
Where Verification has the list of expectations that Rocks.Instantiate<T> used during construction. It would then simply loop through that list and call Verify() on each expectation.
Additional context
The above example has one flaw: it doesn't allow the caller to specify which ctor to use. Here's ChatGPT's attempt at solving that. I didn't want to muddy the waters above but since I already generated the example figured why not include it...
public static T Create<T, E>(IList<E> explicitImplementations, Type[] constructorSignature = null) where T : class where E: Expectations
{
ConstructorInfo constructor;
if (constructorSignature != null)
{
// Find the constructor that matches the provided parameter types
constructor = typeof(T).GetConstructor(constructorSignature);
if (constructor == null)
{
throw new ArgumentException("No constructor found with the specified signature.");
}
}
else
{
// If no signature is provided, use the constructor with the most parameters (default behavior)
var constructors = typeof(T).GetConstructors();
constructor = constructors.OrderByDescending(c => c.GetParameters().Length).First();
}
// Get the constructor parameters (dependencies)
var constructorParameters = constructor.GetParameters();
// Prepare a list to hold the actual instances of the dependencies
var parameterInstances = new List<object>();
foreach (var parameter in constructorParameters)
{
// Check if an explicit implementation is provided for this parameter
var explicitImpl = explicitImplementations.FirstOrDefault(x => parameter.ParameterType.IsAssignableFrom(x.GetType()));
if (explicitImpl != null)
{
parameterInstances.Add(explicitImpl);
}
else
{
// If no explicit implementation is provided, create a new instance
var dependencyInstance = Activator.CreateInstance(parameter.ParameterType);
parameterInstances.Add(dependencyInstance);
}
}
// Create an instance of T using the resolved dependencies
return (T)constructor.Invoke(parameterInstances.ToArray());
}
Here are my thoughts around this.
My first reaction is, how is this idea easier than this?
var fooMock = new IFooCreateExpectations();
fooMock
.Methods
.Foo()
.ReturnValue(true);
var barMock = new IBarCreateExpectations();
barMock
.Methods
.Bar();
var bazMock = new IBazCreateExpectations();
var underTest = new MyClass(fooMock.Instance(), barMock.Instance(), bazMock.Instance());
underTest.DoSomething();
fooMock.Verify();
barMock.Verify();
bazMock.Verify();
Yes, this is more code, though an argument can be made that this is more explicit. Also, if you don't care what the IBaz instance does here, you could use a "make". Though in this case, I think you'd want to use a "create" because you want to ensure that bazMock wasn't used at all, so you'd want to call Verify() on it to ensure that's the case.
I have considered having the expectations class implement IDisposable, so code analysis could help a developer by warning when Dispose() isn't called (CA2000). Dispose() would then call Verify() (and maybe Rocks eventually obsoletes Verify()). This is discussed here. I did make a go at this in the past, but...it made the code in some tests flow rather awkwardly. I forget exactly what didn't "feel" right, but I decided to back off on the idea and potentially revisit in the future. Also, I do feel leery about using IDisposable in a way that it really isn't meant for, which is to release unmanaged resources. I know other libraries have used IDisposable as a way to do a "defer" that other languages have (Odin and a way to "fake" it in C# - https://stu.dev/defer-with-csharp8/) and I wish C# had this, because I think that would be the right way to do a Verify(). Using this instantiation technique reduces the amount of Verify() calls you need to make, but there's still the potential to forget to call Verify().
We're also assuming that we're always going to be passing into a constructor. What if I need to pass mocks into a method? We'd want to do this for a method as well. Probably something like Rocks.Invoke(...).
I get that you said that RocksKnownExpectations would need some thought in terms of how this would exactly work. Service locators always make me leery, so I'm hoping there's an alternative approach.
What about argument order? Do we assume that the order of the expectations will "match" the ones in the constructor? If the constructor has multiple arguments of the same type, which one do you pick? This may not be an issue, but something that needs to be thought out.
What about arguments that aren't expectations? For example, a constructor that has a string parameter. Do we need to pass that into the list? I think we'd need to, and then you can't use the where E : Expectations constraint.
Rocks will need to have knowledge of IFoo, IBar, and IBaz. Since Rocks uses code generation, it should be possible as part of that generation to have Rocks build up a list of known expectations
It can't. Especially if the target type definition exists in another assembly. There's no way (AFAIK) to get the source code for an assembly. And I'm not going to parse IL :).
Side note: now that 9.1.0 will include RockContext, that code becomes even simpler:
using var context = new RockContext();
var fooMock = context.Create<IFooCreateExpectations>();
fooMock
.Methods
.Foo()
.ReturnValue(true);
var barMock = context.Create<IBarCreateExpectations>();
barMock
.Methods
.Bar();
var bazMock = context.Create<IBazCreateExpectations>();
var underTest = new MyClass(fooMock.Instance(), barMock.Instance(), bazMock.Instance());
underTest.DoSomething();
I think this is potentially a lot of work, and it's unclear what the benefit would be, if it could even be done. I'm going to close this issue. If I'm missing something, feel free to re-open.