Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for adding component factories/doubles to test context #388

Closed
6 of 8 tasks
egil opened this issue Apr 30, 2021 · 16 comments · Fixed by #391
Closed
6 of 8 tasks

Support for adding component factories/doubles to test context #388

egil opened this issue Apr 30, 2021 · 16 comments · Fixed by #391
Assignees
Labels
enhancement New feature or request

Comments

@egil
Copy link
Member

egil commented Apr 30, 2021

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)!;
  }
}
@egil egil self-assigned this Apr 30, 2021
@egil egil added the enhancement New feature or request label Apr 30, 2021
@egil egil changed the title Support for adding component doubles (think test doubles) Support for adding component doubles (aka test doubles) to test context May 1, 2021
@egil
Copy link
Member Author

egil commented May 4, 2021

Dummies

A dummy component is a component that simply replaces another component in the render tree, but does nothing else besides that, i.e. no rendered output of any kind.

Minimum requirements include:

  1. Capture paramters passed to the original component it replaces. This is supported through the [Parameter(CaptureUnmatchedValues = true)] feature in Blazor.
  2. Expose captured parameters for assertion.
  3. Expose what component it has replaced

This implementation does the above.

public class Dummy<TComponent> : ComponentBase
{
  [Parameter(CaptureUnmatchedValues = true)]
  public IReadOnlyDictionary<string, object> Parameters { get; set; } = default!;
}

We need an extension method that makes it easy to set up a replacement of a component with the dummy. My current suggestion is AddDummyFor<TComponent>().

@KristofferStrube
Copy link
Contributor

I think Dummy is the correct term to use for the extensions method. It makes sense to use AddDummyFor because it acts on a collection, but from an outside view, it does not describe which effects this adding will have. It could maybe be RegisterDummyFor, InjectDummyFor, ReplaceWithDummy, or ReplaceInstancesWithDummyFor.
I don't have any specific preferences so I'm just spit-balling some of my thoughts. AddDummyFor is also a fine name if that fits the naming of similar extension methods.

Another thing that might be useful to add could be to be able to use a base class or interface if you have a group of components that you don't want to test in the current cut.

@egil
Copy link
Member Author

egil commented May 4, 2021

It could maybe be RegisterDummyFor, InjectDummyFor, ReplaceWithDummy, or ReplaceInstancesWithDummyFor.

Yeah, I am not sure about the name either.

Another thing that might be useful to add could be to be able to use a base class or interface if you have a group of components that you don't want to test in the current cut.

That should already be possible with the base functionality made available with the IComponentDoubleFactory. The CanDouble(Type componentType) method would simply inspect the base class or namespace of componentType and say return true if they match whatever base type or namespace you want to double.

@KristofferStrube
Copy link
Contributor

Cool. I can imagine using that. 👍

@egil
Copy link
Member Author

egil commented May 5, 2021

Thinking about simply calling these things IComponentFactory and then giving the collection property on TestContext the name ComponentFactories. It seems more close to what it actually is, e.g.:

public interface IComponentFactory
{
  bool CanCreate(Type componentType);
  IComponent Create(Type componentType);
}

And an example factory that creates a Dummy<T> component:

public class ComponentDummyFactory : IComponentFactory
{
  private readonly Type componentToDouble;
  
  public ComponentDummyFactory(Type componentToDouble)
  {
    this.componentToDouble = componentToDouble;
  }
  
  public bool CanCreate(Type componentType)
    => componentType == componentToDouble;
  
  public IComponent Create(Type componentType)
    => (IComponent)Activator.CreateInstance(typeof(Dummy<>).MakeGenericType(componentType))!;
}

public static class ComponentFactoriesExtensions
{
  public static ICollection<IComponentFactory> AddDummyFactoryFor<TComponent>(this ICollection<IComponentFactory> factories)
  {
    factories.Add(new ComponentDummyFactory(typeof(TComponent));
    return factories;
  }
}

And here is it used in a test:

using var ctx = new TestContext();
ctx.ComponentFactories.AddDummyFactoryFor<MyChildComponent>();

var cut = ctx.RenderComponent<MyParentComponent>();

Assert.Empty(cut.FindComponents<MyChildComponent>());
Assert.NotEmpty(cut.FindComponents<Dummy<MyChildComponent>>());

@KristofferStrube
Copy link
Contributor

ctx.ComponentFactories.AddDummyFactoryFor<MyChildComponent>();

This looks great. Conveys that this will create dummies using the "factory" for this specific type.

public interface IComponentFactory
{
bool CanCreate(Type componentType);
IComponent Create(Type componentType);
}

This also opens up for potentially creating other types of factories in the future. Like a factory for a generic mocked component or something like that.

@egil
Copy link
Member Author

egil commented May 5, 2021

Another alternative is to use the name TestContext.ComponentCreationRules and have a concept of IComponentCreationRule.

Then the vocabulary could be e.g.:

ctx.ComponentCreationRules.AddDummyFor<TComponent>()

... or perhaps

ctx.ComponentCreationRules.ReplaceWithDommy<TComponent>()

It's pretty wordy thought, but makes it very obvious what is going on.

@KristofferStrube
Copy link
Contributor

Or maybe

ctx.ComponentCreationRules.UseDummyFor<TComponent>()

Which also has wording like a rule: "For this type use dummy"

@mrpmorris
Copy link

ComponentFactories is good.

Also consider

ComponentFactories.Register()
ComponentFactories.Register(type)

And also overrides that accept a Func<T, IComponent> parameter

@egil
Copy link
Member Author

egil commented May 6, 2021

Thanks for the input Peter.

ComponentFactories.Register()
ComponentFactories.Register(type)

I think the convention in .net is to use Add for collection like types, or?

And also overrides that accept a Func<T, IComponent> parameter

Can you elaborate on how this would work?

@mrpmorris
Copy link

I'm just thinking that if you had a base factory built in that just returned a stub IComponent then you could use ComponentFactories.Stub()

The Register suggestion was along a similar line, but where it would return what the coder specified rather than a stubbd IComponent - the purpose here being that we don't have to write a factory for each component type we want in there.

ComponentFactories.Stub(); => Mocked IComponent that does nothing
ComponentFactories.Register<ChildComponent, SomeTypeThatImplementsIComponent>();
ComponentFactories.Register(User supplied Func that returns IComponent)

@egil
Copy link
Member Author

egil commented May 7, 2021

@mrpmorris, I think I get what you are saying.

What I am thinking currently is having base functionality added to TestContext, that allows for me and others to provide helper methods/extensions, that enables the mocking/faking/stubbing/etc.

So something like this will be added to bUnit.core:

public interface IComponentFactory
{
  bool CanCreate(Type componentType);
  IComponent Create(Type componentType);
}

public interface IComponentFactoryCollection : IList<IComponentFactory> { }

Then this property will show up in TestContextBase:

IComponentFactoryCollection ComponentFactories { get; }

And then we and others can create extensions methods on IComponentFactoryCollection that match the vocabulary they like, e.g. UseStubFor<TComponent>() or Register<TComponent, TReplacementComponent>().

I am leaning to wards calling the new property in TestContext for ComponentFactories since that is in essence what is being exposed, a collection of component factories that will be used when rendering components using bUnit.

The naming of the extension methods that build on this functionality I am still not sure of. In theory, these extension methods could be on TestContext, e.g.:

using var ctx = new TestContext();
ctx.UseStubFor<TComponent>();
// ...

@egil egil changed the title Support for adding component doubles (aka test doubles) to test context Support for adding component factories to test context May 8, 2021
@egil egil changed the title Support for adding component factories to test context Support for adding component factories/doubles to test context May 8, 2021
@egil egil linked a pull request May 8, 2021 that will close this issue
9 tasks
@egil egil closed this as completed in #391 May 13, 2021
@ChristopheDEBOVE
Copy link

It's seems the current documentation is not updated, this feature is a must to have,thank you all for all the effort you've made. unfortunately I don't find a way to use it.
Is the extension method located in a particular nuget package version?

@egil
Copy link
Member Author

egil commented Jul 14, 2021

Hey @ChristopheDEBOVE,

Thank you, and thanks for your support!

The feature is in the preview release of bUnit you can find on nuget.org. I'm going to document it soon after releasing it/going out in a non preview package.

There might still be done changes to the feature coming based on feedback, so that's why I'm holding off on the docs for now.

@ChristopheDEBOVE
Copy link

@egil Thank you I found it. very valuable feature !!!

@egil
Copy link
Member Author

egil commented Jul 14, 2021

Btw. Here is an example for using the built-in component factories in bUnit: #450 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants