Use ServiceCollection in Unit Tests with .NET

Use ServiceCollection in Unit Tests with .NET

A popular unit test - and also a necessary test - is the correct registration of interfaces and their implementation with dependency injection. And a common mistake is that the associated IServiceCollection interface is used for mocks that lead to faulty tests.

The problem

Given the following code, in which an interface and an implementation are registered in a .NET application.

using Microsoft.Extensions.DependencyInjection;
using System;

public interface IMyService
{
    void DoSomething();
}

public class MyService : IMyService
{
    public void DoSomething()
    {
        Console.WriteLine("Doing something...");
    }
}

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // Registering the singleton
        services.AddSingleton<IMyService, MyService>();
    }
}

An obvious test would be to check that AddSingleton of the interface is called.

public class StartupTests
{
    [Fact]
    public void ConfigureServices_ShouldRegisterIMyServiceAsSingleton()
    {
        // Arrange
        IServiceCollection services = Substitute.For<IServiceCollection>();
        Startup startup = new Startup();

        // Act
        startup.ConfigureServices(services);

        // Assert
        services.Received().AddSingleton<IMyService, MyService>();
    }
}

The problem is that AddSingleton is a simple extension method that itself cannot be easily mocked and thus used for mocked tests.

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

// Microsoft.Extensions.DependencyInjection.Abstractions.dll

public static IServiceCollection AddSingleton<TService, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TImplementation>(this IServiceCollection services)
    where TService : class
    where TImplementation : class, TService
{
    ThrowHelper.ThrowIfNull(services);

    return services.AddSingleton(typeof(TService), typeof(TImplementation));
}

A corresponding test fails with the following error message:

Message: NSubstitute.Exceptions.ReceivedCallsException : Expected to receive a call matching: Add(ServiceType: IMyService Lifetime: Singleton ImplementationType: MyService) Actually received no matching calls. Received 1 non-matching call (non-matching arguments indicated with '*' characters): Add(ServiceType: IMyService Lifetime: Singleton ImplementationType: MyService)

Use of ServiceCollection

The generally recommended solution in the community is not to use the interface IServiceCollection for testing, but the implementation ServiceCollection.

// Arrange
ServiceCollection services = new ServiceCollection();
Startup startup = new Startup();

// Act
startup.ConfigureServices(services);

// Assert
ServiceDescriptor? serviceDescriptor = services
    .Where(
        serviceDescriptor => serviceDescriptor.ServiceType == typeof(IMyService)
        && serviceDescriptor.ImplementationType == typeof(MyService)
        && serviceDescriptor.Lifetime == ServiceLifetime.Singleton)
    .SingleOrDefault();

Assert.NotNull(serviceDescriptor);

The enormous advantage is that I am not testing a mock, but the real implementation of ServiceCollection, which is also used at runtime. Furthermore, asserting ServiceCollection in more complex tests is much easier.

To make it even easier, you can build simple test extensions to reduce the test effort per test:

/// <summary>
/// Provides extension methods for verifying the registration of services in a <see cref="ServiceCollection"/>.
/// </summary>
public static class ServiceCollectionExtensions
{
    /// <summary>
    /// Verifies that a specific service and its implementation are registered in the <see cref="ServiceCollection"/> 
    /// with the <see cref="ServiceLifetime.Singleton"/> lifetime the specified number of times.
    /// </summary>
    /// <typeparam name="TService">The service type to verify.</typeparam>
    /// <typeparam name="TImplementation">The implementation type of the service to verify.</typeparam>
    /// <param name="collection">The <see cref="ServiceCollection"/> to check.</param>
    /// <param name="times">The expected number of times the service and its implementation are registered.</param>
    public static void VerifyAddSingleton<TService, TImplementation>(this ServiceCollection collection, int times = 1)
        where TImplementation : class, TService
    {
        VerifyAdd<TService, TImplementation>(collection, ServiceLifetime.Singleton, times);
    }

    /// <summary>
    /// Verifies that a specific service and its implementation are registered in the <see cref="ServiceCollection"/> 
    /// with the specified lifetime the specified number of times.
    /// </summary>
    /// <typeparam name="TService">The service type to verify.</typeparam>
    /// <typeparam name="TImplementation">The implementation type of the service to verify.</typeparam>
    /// <param name="collection">The <see cref="ServiceCollection"/> to check.</param>
    /// <param name="lifetime">The expected lifetime of the service.</param>
    /// <param name="times">The expected number of times the service and its implementation are registered.</param>
    public static void VerifyAdd<TService, TImplementation>(this ServiceCollection collection, ServiceLifetime lifetime, int times)
        where TImplementation : class, TService
    {
        // Arrange
        Type serviceType = typeof(TService);
        Type implementationType = typeof(TImplementation);

        // Act
        List<ServiceDescriptor> items = collection
            .Where(
                serviceDescriptor => serviceDescriptor.ServiceType == typeof(TService)
                && serviceDescriptor.ImplementationType == typeof(TImplementation)
                && serviceDescriptor.Lifetime == lifetime)
            .ToList();

        // Assert
        Assert.Equal(times, items.Count);
    }

    /// <summary>
    /// Verifies that a specific service is registered in the <see cref="ServiceCollection"/> 
    /// with the specified lifetime the specified number of times.
    /// </summary>
    /// <typeparam name="TService">The service type to verify.</typeparam>
    /// <param name="collection">The <see cref="ServiceCollection"/> to check.</param>
    /// <param name="lifetime">The expected lifetime of the service.</param>
    /// <param name="times">The expected number of times the service is registered.</param>
    public static void VerifyAdd<TService>(this ServiceCollection collection, ServiceLifetime lifetime, int times)
    {
        // Arrange
        Type serviceType = typeof(TService);

        // Act
        List<ServiceDescriptor> items = collection
            .Where(
                serviceDescriptor => serviceDescriptor.ServiceType == typeof(TService)
                && serviceDescriptor.Lifetime == lifetime)
            .ToList();

        // Assert
        Assert.Equal(times, items.Count);
    }
}

The test looks correspondingly slimmer:

// Arrange
ServiceCollection services = new ServiceCollection();
Startup startup = new Startup();

// Act
startup.ConfigureServices(services);

// Assert
services.VerifyAddSingleton<IMyService, MyService>();

Using NSubstitute

A potential further solution with NSubstitute is to mock the base method Add and check whether it has been called with the appropriate parameters.

// Arrange
IServiceCollection services = Substitute.For<IServiceCollection>();
Startup startup = new Startup();

// Act
startup.ConfigureServices(services);

// Assert
services.Received().Add(Arg.Is<ServiceDescriptor>(descriptor =>
    descriptor.ServiceType == typeof(IMyService) &&
    descriptor.ImplementationType == typeof(MyService) &&
    descriptor.Lifetime == ServiceLifetime.Singleton));

Conclusion

Simpler and better quality tests can be achieved when verifying dependency injection with ServiceCollection.