Skip to content
This repository has been archived by the owner on Sep 22, 2022. It is now read-only.

rsivanov/Rsi.DependencyInjection

Repository files navigation

Rsi.DependencyInjection

Build NuGet NuGet

This project implements a couple of extension methods for Microsoft.Extensions.DependencyInjection to allow mocking services inside unit or integration tests without rebuilding the DI container.

Why it matters? Imagine that you have several hundred or even thousands of integration tests where you mock services using ConfigureTestServices. In every such a test you'll have to rebuild the container completely and it'll take a significant amount of time.

You can cut the total test running time by an order of magnitude, if you build the DI container only once.

Inspired by

I've been using Autofac for quite some time before moving to Microsoft.Extensions.DependencyInjection as my DI container of choice. Autofac has the ability to add service registrations on the fly without rebuilding the container.

using(var scope = container.BeginLifetimeScope(
  builder =>
  {
    builder.RegisterType<Override>().As<IService>();
    builder.RegisterModule<MyModule>();
  }))
{
  // The additional registrations will be available
  // only in this lifetime scope.
}

I moved from Autofac to Microsoft.Extensions.DependencyInjection to get a much faster DI container, but an outstanding runtime performance of Microsoft.Extensions.DependencyInjection unfortunately leads to slow integration testing due to the static nature of MS DI container - once it's built, you can't easily change it for mocking purposes.

And that's when I thought, what if we could have a similar extension method to change service registrations after the container is built.

How to use

Inside your test host builder code add a call to DecorateServicesForTesting as the last one after completing all service registrations.

services.DecorateServicesForTesting(t => t.IsMockable());

The only parameter to that method is a criteria - whether you going to mock a service type or not. An example from SampleWebApi:

public static bool IsMockable(this Type t)
{
    return t.IsFromOurAssemblies() ||
           t.IsOptionType() && t.GenericTypeArguments[0].IsFromOurAssemblies();
}

private static bool IsFromOurAssemblies(this Type t)
{
    return t.Assembly.GetName().Name.StartsWith("Rsi.");
}

private static bool IsOptionType(this Type t)
{
    return t.IsClosedTypeOf(typeof(IConfigureOptions<>)) ||
           t.IsClosedTypeOf(typeof(IPostConfigureOptions<>)) ||
           t.IsClosedTypeOf(typeof(IOptions<>)) ||
           t.IsClosedTypeOf(typeof(IOptionsSnapshot<>)) ||
           t.IsClosedTypeOf(typeof(IOptionsMonitor<>));
}

Here we mock only service types from our assemblies and IOptions* closed-generic interfaces where option types come from our assemblies.

Then after building a test host only once for all tests (using XUnit FixtureCollection) you can mock service registrations locally inside your integration tests using another extension method CreateScope:

[Fact]
public async Task GetSampleValue_WhenCalledWithMock_ReturnsMockValue()
{
    using var mockServiceScope = _testHostServiceProvider.CreateScope(mockServices =>
    {
        var mockSampleService = Substitute.For<ISampleService>();
        mockSampleService.GetSampleValue().ReturnsForAnyArgs("Mock value");
        mockServices.AddSingleton(_ => mockSampleService);
    });
    var sampleController = RestClient.For<ISampleController>(_testHost.HttpClient);
    
    var sampleValue = await sampleController.GetSampleValue();
    Assert.Equal("Mock value", sampleValue);
}

As you can see, it's similar to Autofac BeginLifetimeScope, but for Microsoft.Extensions.DependencyInjection.

Benchmarks

I used BenchmarkDotNet to compare running time of integration tests with mocks using ConfigureTestServices (which is recommended by Microsoft in the official documentation) and CreateScope:

public class TestHostBenchmarks
{
    private static readonly WebApplicationFactory<Startup> Factory = new WebApplicationFactory<Startup>();
    
    private static readonly HttpClient Client;
    private static readonly IServiceProvider RootScope;

    static TestHostBenchmarks()
    {
        var testServer = Factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services => services.DecorateServicesForTesting(t => t.IsMockable()));
        }).Server;
        testServer.PreserveExecutionContext = true;
        
        Client = testServer.CreateClient();
        RootScope = testServer.Services.CreateScope().ServiceProvider;
    }
    
    [Benchmark]
    public async Task ConfigureTestServices()
    {
        var client = Factory.WithWebHostBuilder(builder =>
            {
                builder.ConfigureTestServices(mockServices =>
                {
                    var mockSampleService = Substitute.For<ISampleService>();
                    mockSampleService.GetSampleValue().ReturnsForAnyArgs("Mock value");
                    mockServices.AddSingleton(_ => mockSampleService);					
                });
            })
            .CreateClient();
        
        var sampleController = RestClient.For<ISampleController>(client);
        
        var sampleValue = await sampleController.GetSampleValue();
        Assert.Equal("Mock value", sampleValue);
    }

    [Benchmark]
    public async Task CreateScope()
    {
        using var mockServiceScope = RootScope.CreateScope(mockServices =>
        {
            var mockSampleService = Substitute.For<ISampleService>();
            mockSampleService.GetSampleValue().ReturnsForAnyArgs("Mock value");
            mockServices.AddSingleton(_ => mockSampleService);
        });
        var sampleController = RestClient.For<ISampleController>(Client);
        
        var sampleValue = await sampleController.GetSampleValue();
        Assert.Equal("Mock value", sampleValue);			
    }
}

Here are the benchmark results:

BenchmarkDotNet=v0.12.1, OS=macOS Catalina 10.15.5 (19F101) [Darwin 19.5.0]
Intel Core i9-9880H CPU 2.30GHz, 1 CPU, 16 logical and 8 physical cores
.NET Core SDK=3.1.102
  [Host] : .NET Core 3.1.2 (CoreCLR 4.700.20.6602, CoreFX 4.700.20.6702), X64 RyuJIT

Job=InProcess  Server=True  Toolchain=InProcessEmitToolchain  
Method Mean Error StdDev Median
ConfigureTestServices 71,879.9 μs 2,195.18 μs 6,438.07 μs 69,563.8 μs
CreateScope 119.8 μs 0.68 μs 0.61 μs 119.7 μs

As you can see, CreateScope is 600 times faster than ConfigureTestServices, and the reason for this is quite simple - it doesn't require to rebuild the test container when you need to replace service implementations!

How it works

For all existing service registrations that meet a decoration criteria we change Singleton lifetimes to Scoped and replace service descriptors with a custom factory inside DecorateServicesForTesting. When you open a new scope with mock service registrations the factory uses that information when returns a service implementation inside a scope.

var newServices = new ServiceCollection();
foreach (var wrappedServiceDescriptor in services)
{
    Func<IServiceProvider, object> objectFactory = serviceProvider =>
    {
        var currentScope = NestedServiceScope.GetCurrentScopeByRootServices(services);
        if (currentScope == null)
            return serviceProvider.CreateInstance(wrappedServiceDescriptor);

        var serviceDescriptor =
            currentScope.GetClosestServiceDefinition(wrappedServiceDescriptor.ServiceType);
        return serviceDescriptor == null
            ? serviceProvider.CreateInstance(wrappedServiceDescriptor)
            : serviceProvider.CreateInstance(serviceDescriptor);
    };

    if (decorationCriteria(wrappedServiceDescriptor.ServiceType))
    {
        //We can't mock classes and open-generic interfaces
        if (wrappedServiceDescriptor.ServiceType.IsClass ||
            wrappedServiceDescriptor.ServiceType.IsGenericType &&
            wrappedServiceDescriptor.ServiceType.ContainsGenericParameters)
        {
            newServices.Add(wrappedServiceDescriptor);
        }
        else
        {
            //We change decorated services Singleton lifetimes to Scoped to make mocked registrations local
            //to a concrete mock scope
            var serviceLifetime = wrappedServiceDescriptor.Lifetime == ServiceLifetime.Singleton
                ? ServiceLifetime.Scoped
                : wrappedServiceDescriptor.Lifetime;
            newServices.Add(ServiceDescriptor.Describe(wrappedServiceDescriptor.ServiceType, 
                objectFactory, serviceLifetime));
        }
    }
    else
    {
        newServices.Add(wrappedServiceDescriptor);
    }
}
services.Clear();
services.Add(newServices);

Known limitations

  • We can only replace existing service registrations with new ones and can't add any service registrations that weren't present in the container before it was built. This is due to the static nature of MS DI container.
  • We can't replace open-generic service registrations due to limitations of MS DI factory methods such as:
services.AddSingleton(typeof(IInterface<>), typeof(Implementation<>));

Just change open-generic service registrations to closed-generic versions if you want to mock them.

About

An addition to Microsoft.Extensions.DependencyInjection that allows to mock services without rebuilding a container.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages