-
-
Notifications
You must be signed in to change notification settings - Fork 119
Description
Component doubles make it possible to replace an irrelevant component in the render tree with another component during testing.
The proposal is to add the following to TestContextBase:
public ComponentDoubleCollection ComponentDoubles { get; }The ComponentDoubleCollection is a collection of IComponentDoubleFactory types, which knows what type they can map from and to, and can create the double.
The idea is to keep the ComponentDoubleCollection simple, its basically an ICollection<IComponentDoubleFactory>, and instead offer extension methods which extends and simplifies using the functionality, e.g. AddStub<TComponentToReplace>().
This is how I current think the IComponentDoubleFactory could look:
interface class IComponentDoubleFactory
{
bool CanDouble(Type componentType);
IComponent CreateDouble(Type componentTypeToDouble);
}Example usage
To replace a component, we need something to replace it with. For the very simple case, where we simply want to replace a component with a dummy that does nothing, we can do the following:
Here is an simple implementation of a dummy component, Dummy<TComponent>:
public class Dummy<TComponent> : ComponentBase
{
[Parameter(CaptureUnmatchedValues = true)]
public IReadOnlyDictionary<string, object> Parameters { get; set; } = default!;
}This can be used by a "dummy factory" like this:
public class DirectComponentDoubleTypeFactory : IComponentDoubleFactory
{
private readonly Type componentToDouble;
public DirectComponentDoubleTypeFactory(Type componentToDouble)
{
this.componentToDouble = componentToDouble;
}
public bool CanDouble(Type componentType)
=> componentType == componentToDouble;
public IComponent CreateDouble(Type componentTypeToDouble)
=> (IComponent)Activator.CreateInstance(typeof(Dummy<>).MakeGenericType(componentTypeToDouble))!;
}Then, in a test, we can do something like this to replace MyChildComponent inside MyParentComponent in a test:
using var ctx = new TestContext();
ctx.ComponentDoubles.Add(new DirectComponentDoubleTypeFactory(typeof(MyChildComponent));
var cut = ctx.RenderComponent<MyParentComponent>();
Assert.Empty(cut.FindComponents<MyChildComponent>());
Assert.NotEmpty(cut.FindComponents<Dummy<MyChildComponent>>());Obviously, the line ctx.ComponentDoubles.Add(new DirectComponentDoubleTypeFactory(typeof(MyChildComponent)); can be simplified by an extension method to something like ctx.ComponentDoubles.AddDummyFor<MyChildComponent>();.
Naming
The term "component doubles" comes from the term "test doubles", which is a general term for things like mocks, fakes, stubs.
I am still not sure about the word "double" though. Perhaps the word "replace"/"replacement" is better?
Requirements
The solutions should allow these, which implicitly closes #53 and enables #17 to be closed, with some limitations on concurrent renders.
- Allow users to replace a component type with a component produced by a factory at runtime.
- Allow users to replace a component type with another component type.
- Allow users to replace a component type with a specific component instance.
- Allow users to easily replace all components from an assembly.
- Allow users to easily replace all components in a namespace.
- Allow users to specify "all but these components" should be replaced.
All of these requirements seems to be fairly trivial to support with the right IComponentDoubleFactory added to the ComponentDoubles collection on TestContext.
The next question is what/which IComponentDoubleFactory's should come with bUnit out of the box.
Questions and investigations
- What happens if a doubled component is referenced by another component, i.e. with the
@ref="childComponent"? - Could mock components be automatically generated using source generators, which inherits from the replaced component, and just overwrite the life cycle methods.
Implementation details
This utilizes the IComponentActivator in .NET 5 and later versions of Blazor. The idea is to have a BunitComponentActivator that knows about the IComponentDoubleFactory added to the ComponentDoubleCollection and will run through those and try to find the first one that can create a double for a component. This should happen in reverse order, so it tries the last added activator first. If not, it will just create the requested component.
This is the current basic implementation of the BunitComponentActivator , which is passed to the renderer, and is used to instantiate components:
internal class BunitComponentActivator : IComponentActivator
{
private readonly ComponentDoubleCollection doubleFactories;
public BunitComponentActivator(ComponentDoubleCollection doubleFactories)
{
this.doubleFactories = doubleFactories ?? throw new ArgumentNullException(nameof(doubleFactories));
}
public IComponent CreateInstance([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type componentType)
{
if (!typeof(IComponent).IsAssignableFrom(componentType))
{
throw new ArgumentException($"The type {componentType.FullName} does not implement {nameof(IComponent)}.", nameof(componentType));
}
for(var i = doubleFactories.Length-1; i >= 0; i--)
{
var doubleFactory = doubleFactories[i];
if (doubleFactory.CanDouble(componentType))
{
return doubleFactory.CreateDouble(componentType);
}
}
return (IComponent)Activator.CreateInstance(componentType)!;
}
}