bUnit-dev / bUnit

bUnit is a testing library for Blazor components that make tests look, feel, and runs like regular unit tests. bUnit makes it easy to render and control a component under test’s life-cycle, pass parameter and inject services into it, trigger event handlers, and verify the rendered markup from the component using a built-in semantic HTML comparer.
https://bunit.dev
MIT License
1.11k stars 104 forks source link

Change in 1.27.12-preview breaks Moq/AutoMock with loose behavior #1478

Closed spankr closed 1 month ago

spankr commented 2 months ago

Description

Our team came across this issue when upgrading from 1.25.3. We narrowed it down to the change in 1.27.12-preview, specifically the feature for supporting parameters as query string.

The change to src/bunit.web/TestContext.cs is asking the IServiceProvider for an implementation of IComponentActivator and then implementing WebTestRenderer with it, if it finds one.

The issue for us is that we use AutoMock as the fallback provider. We use loose behavior for mocking (for anyone unfamiliar with mocking behaviors, "loose" essentially means that if a mock is not pre-defined, a default one will be generated/provided for your test.). This means that when line 92 in TestContext.cs asks for an IComponentActivator, AutoMock provides a mocked implementation (and not null).

Our tests then die horribly with this complaint:

System.InvalidOperationException
The component activator returned a null value for a component of type
OurNameSpace.UnitTests.OurTestClass.CanCreateComponent+TestComponent.
   at Microsoft.AspNetCore.Components.ComponentFactory.InstantiateComponent(IServiceProvider serviceProvider, Type componentType, IComponentRenderMode callerSpecifiedRenderMode, Nullable`1 parentComponentId)

Reproduction Steps

We have a simple test scenario

@inherits OurTestBase

@code {
    [Fact]
    public void CanRenderTestComponent()
    {
        RenderComponent<TestComponent>();
    } 

    /// <summary>
    /// Just a component for testing
    /// </summary>
    class TestComponent : ComponentBase { }
}

and our test base class

    public class OurTestBase : TestContext
    {
        public OurTestBase()
        {
            JSInterop.Mode = JSRuntimeMode.Loose;
            Services.Add(ServiceDescriptor.Singleton(JSInterop));
            // Setup the automocker to use BUnit's mocked IJSRuntime
            _mock.Use(JSInterop.JSRuntime);

            // Attach Automocker here
            Services.AddFallbackServiceProvider(new AutoMockerServiceProvider(_mock));
        }

        protected readonly AutoMocker _mock = new AutoMocker(MockBehavior.Loose);

        private class AutoMockerServiceProvider : IServiceProvider
        {
            public AutoMockerServiceProvider(AutoMocker mock)
            {
                _mock = mock;
            }

            public object? GetService(Type serviceType)
            {
                return _mock.Get(serviceType);
            }

            private readonly AutoMocker _mock;
        }
    }

It's a hack but this modification to our code bypasses the problem for us:

            public object? GetService(Type serviceType)
            {
                // 2024.06.10 - Bunit gets angry if you provide a mock of IComponentActivator
                if (serviceType == typeof(IComponentActivator))
                    return null;

                return _mock.Get(serviceType);
            }

Expected behavior

That our tests run without a low-level error in Microsoft.AspNetCore.Components.ComponentFactory.InstantiateComponent.

Actual behavior

Test failure with

System.InvalidOperationException
The component activator returned a null value for a component of type OurNameSpace.UnitTests.OurTestClass.CanCreateComponent+TestComponent.
   at Microsoft.AspNetCore.Components.ComponentFactory.InstantiateComponent(IServiceProvider serviceProvider, Type componentType, IComponentRenderMode callerSpecifiedRenderMode, Nullable`1 parentComponentId)
...

Known Workarounds

When setting up the FallbackServiceProvider, add a check for the type of IComponentActivator and return null.

Other information

Commit 8f99afa

src/bunit.web/TestContext.cs, lines 92-95:

var componentActivator = Services.GetService<IComponentActivator>();
return componentActivator is null
    ? new WebTestRenderer(renderedComponentActivator, Services, logger)
    : new WebTestRenderer(renderedComponentActivator, Services, logger, componentActivator);
linkdotnet commented 2 months ago

Hey @spankr

Thanks for reporting this. This code is mainly for backward compatibility, and Blazor itself has roughly the same piece:

return serviceProvider.GetService<IComponentActivator>()
            ?? new DefaultComponentActivator(serviceProvider);

Source: https://github.com/dotnet/aspnetcore/blob/main/src/Components/Components/src/RenderTree/Renderer.cs#L68-L69

That said, I am unsure if I can follow your expected behavior.

We are basically offering the way for a custom IComponentActivator if available, otherwise we use the bUnit internal one. I can fondly remember that we did this as customer feedback (@egil do you remember the ticket?).

spankr commented 2 months ago

I would think this could also be resolved with a some documentation where it talks about mocking and using a fallback provider. That the fallback provider should not respond with a mocked IComponentActivator.

linkdotnet commented 2 months ago

Indeed, that would be a fair point.

linkdotnet commented 1 month ago

We merged the updated documentation. Thanks for reporting @spankr

Devqon commented 3 weeks ago

For anyone using AutoFixture in combination with automocking like NSubstitute, this is how I have fixed this:

// BunitCustomization.cs
public class BunitCustomization : ICustomization
{
    public void Customize(IFixture fixture)
    {
        fixture.Customize<TestContext>(composer => composer.FromFactory(() =>
        {
            var testContext = new TestContext();
            testContext.Services.AddFallbackServiceProvider(new AutoFixtureServiceProvider(fixture));
            return testContext;
        });

        fixture.Customize<BunitJSInterop>(composer => composer.FromFactory(
            () => fixture.Freeze<TestContext>().JSInterop);

        // This is necessary to prevent an automatically created instance by NSubstitute, which prevents components from actually rendering in BUnit
        fixture.Customize<IComponentActivator>(composer => composer.FromFactory(
            () => (IComponentActivator)null));
    }
}

// AutoFixtureServiceProvider.cs
public class AutoFixtureServiceProvider : IServiceProvider
{
    private readonly IFixture _fixture;

    public AutoFixtureServiceProvider(IFixture fixture) 
        => _fixture = fixture;

    public object? GetService(Type serviceType) 
        => _fixture.Create(serviceType, new SpecimenContext(_fixture));
}

And then you can do in your unittest:

public class MyComponentTests
{
    [Theory, BunitAutoData]
    public void MyComponent_Should_RenderCorrectly(TestContext testContext)
    {
        // Arrange

        // Act
        var renderAction = () => testContext.RenderComponent<MyComponent>();

        // Assert
        renderAction.Should().NotThrow();
    }
}