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
.