Monday, February 27, 2023

Testing of dependency tree

Today, the use of dependency containers is widespread. The constructor of your classes accepts instances of other classes, they depend on other classes, etc. And the dependency container manages the construction of the entire instance tree.

This system has its price. For example, during testing, you must create instances of all the dependencies of a class in order to test this class. You can use something like Moq for this task. But in this case, there is a problem with class changes. If you want to add or remove any constructor parameter, you will also have to change the tests, even if this parameter does not affect them.

There is another task that we want to solve during testing. Let's say we want to test the work of not one isolated class, but the joint work of several classes in some part of our system. Our dependency container creates a whole tree of instances of various classes. And you want to test the whole tree. Let's see how we can do this, what obstacles we will face and how we can overcome them.

Resilience to constructor changes

Let's say we have a class we want to test:

    
public class System
{
    public System(
        IService1 service1,
        IService2 service2
    )
    {
        ...
    }

    ...
}
    

Usually the tests for such cases look like this:

    
[TestMethod]
public void SystemTest()
{
    var service1Mock = new Mock<IService1>();
    var service2Mock = new Mock<IService2>();

    var system = new System(
        service1Mock.Object,
        service2Mock.Object
    );

    ...
}
    

But today I want to add logging to my System class:

    
public class System
{
    public System(
        IService1 service1,
        IService2 service2,
        ILogger logger
    )
    {
        ...
    }

    ...
}
    

Now the tests for this class are not compiled. I have to go to all the places where I create an instance of the System class and change the code there:

    
[TestMethod]
public void SystemTest()
{
    var service1Mock = new Mock<IService1>();
    var service2Mock = new Mock<IService2>();
    var loggerMock = new Mock<ILogger>();

    var system = new System(
        service1Mock.Object,
        service2Mock.Object,
        loggerMock.Object
    );

    ...
}
    

Of course, to reduce the amount of work, I can move the code that creates an instance of the class to a separate method. Then I won't need to make changes in many places.:

    
private Mock<IService1> service1Mock = new();
private Mock<IService2> service2Mock = new();
private Mock<ILogger> loggerMock = new();

private System CreateSystem()
{
    return new System(
        service1Mock.Object,
        service2Mock.Object,
        loggerMock.Object
    );
}

[TestMethod]
public void SystemTest()
{
    var system = CreateSystem();

    ...
}
    

But this approach also has its drawbacks. I still had to create the ILogger mock, even though I don't need it. I only use it to pass to the constructor of my class.

Fortunately, there is AutoMocker. You just create an instance of your class using CreateInstance:

    
private AutoMocker _autoMocker = new();

[TestMethod]
public void SystemTest()
{
    var system = _autoMocker.CreateInstance<System>();

    ...
}
    

This method can create instances of any class, even sealed one. It works like a dependency container, analyzing the constructor and creating mocks for its parameters.

In any moment you can get any mock you want to set its behavior or verify calls of its methods:

    
var service1Mock = _autoMocker.GetMock<IService1>();
    

Also, if you don't want to use Moq mock, but you have your own implementation of some interface, you can do it before calling CreateInstance:

    
var testService1 = new TestService1();
_autoMocker.Use<IService1>(testService1);
    

Cool! Now you can freely change the signature of the constructor without fear that you'll have to change tests in thousand of places.

However, the fact that tests continue to compile does not mean that they will continue to pass after changes in the class. On the other hand, it has been said many times that tests should check the class contract, not its implementation. If the contract is not changed, the tests must still pass. If the contract is changed, there is no way to avoid changing the tests.

However, before we started using AutoMocker, we immediately saw which tests were affected by our changes in the constructor, and could only run these tests. Now we will probably have to run all the tests unless we have some kind of agreement on where we store all the tests for one class. But here everyone has to choose for himself.

And we continue.

Testing with dependencies

One of my colleagues suggested taking another step forward. In fact, in our application we are still creating a dependency container. There we register all our classes and interfaces. So why don't we take instances of classes for testing from this container? In this case, we will test the tree of objects that we actually use in production. This would be very useful for integration tests.

For example, our dependency registration code looks like this:

    
services.AddLogging();
services.AddDomainClasses();
services.AddRepositories();
...
    

We move it to a separate method:

    
public static class ServicesConfiguration
{
    public static void RegisterEverything(IServiceCollection services)
    {
        services.AddLogging();
        services.AddDomainClasses();
        services.AddRepositories();
        ...
    }
}
    

and use this method to register our services:

    
ServicesConfiguration.RegisterEverything(services);
    

Now we can use this method in tests as well:

    
[TestMethod]
public void SystemTest()
{
    IServiceCollection services = new ServiceCollection();
    ServicesConfiguration.RegisterEverything(services);
    var provider = services.BuildServiceProvider();

    using var scope = provider.CreateScope();

    var system = scope.ServiceProvider.GetRequiredService<System>();

    ...
}
    

And even if your class is not registered in the dependency container, but you just want to take the parameters for its constructor from there, you can do it as follows:

    
var system = ActivatorUtilities.CreateInstance<System>(_scope.ServiceProvider);
    

Naturally, it may be necessary to make some changes to the registered services. For example, you may want to change the database connection strings if you don't use IConfiguration to get them:

    
IServiceCollection services = new ServiceCollection();
Configuration.RegisterEverything(services);

services.RemoveAll<IConnectionStringsProvider>();
services.AddSingleton<IConnectionStringsProvider>(new TestConnectionStringsProvider());
    

And if you use IConfiguration, you can create your own configuration using in-memory storage:

    
var builder = new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile("appSettings.json", optional: true, reloadOnChange: true)
    .AddInMemoryCollection(settings);

var configuration = builder.Build();
    

But even in this case, we may still want to use mocks in some situations. You see, even in integration tests there are external dependencies that you don't control. If you can still recreate your own database, there are external services that you use via HTTP requests. You still want to imitate these external requests using mocks.

To support mocks, we need to write a certain amount of code. I moved all the logic related to getting service instances and managing mocks into one class:

    
public class SimpleConfigurator : IServiceProvider, IDisposable
{
    private readonly IDictionary<Type, Mock> _registeredMocks = new Dictionary<Type, Mock>();
    private readonly IServiceCollection _services;

    private IServiceProvider _serviceProvider;
    private IServiceScope? _scope;
    private bool _configurationIsFinished = false;

    public SimpleConfigurator(IServiceCollection services)
    {
        _services = services;
    }

    public void Dispose()
    {
        _scope?.Dispose();
    }

    /// <summary>
    /// Creates instance of <typeparamref name="T"/> type using dependency container
    /// to resolve constructor parameters.
    /// </summary>
    /// <typeparam name="T">Type of instance.</typeparam>
    /// <returns>Instance of <typeparamref name="T"/> type.</returns>
    public T CreateInstance<T>()
    {
        PrepareScope();

        return ActivatorUtilities.CreateInstance<T>(_scope!.ServiceProvider);
    }

    /// <summary>
    /// Returns service registered in the container.
    /// </summary>
    /// <param name="serviceType">Service type.</param>
    /// <returns>Instance of a service from the container.</returns>
    public object? GetService(Type serviceType)
    {
        PrepareScope();

        return _scope!.ServiceProvider.GetService(serviceType);
    }

    /// <summary>
    /// Replaces in the dependency container records of <typeparamref name="T"/> type
    /// with a singleton mock and returns the mock.
    /// </summary>
    /// <typeparam name="T">Type of service.</typeparam>
    /// <returns>Mock for the <typeparamref name="T"/> type.</returns>
    /// <exception cref="InvalidOperationException">This method can't be called after
    /// any service is resolved from the container.</exception>
    public Mock<T> GetMock<T>()
        where T : class
    {
        if (_registeredMocks.ContainsKey(typeof(T)))
        {
            return (Mock<T>)_registeredMocks[typeof(T)];
        }

        if (!_configurationIsFinished)
        {
            var mock = new Mock<T>();

            _registeredMocks.Add(typeof(T), mock);

            _services.RemoveAll<T>();
            _services.AddSingleton(mock.Object);

            return mock;
        }
        else
        {
            throw new InvalidOperationException($"You can not create new mock after any service is already resolved (after call of {nameof(CreateInstance)} or {nameof(GetService)})");
        }
    }

    private void PrepareScope()
    {
        if (!_configurationIsFinished)
        {
            _configurationIsFinished = true;

            _serviceProvider = _services.BuildServiceProvider();

            _scope = _serviceProvider.CreateScope();
        }
    }
}
    

Let's examine this class in more detail.

This class implements the standard IServiceProvider interface, so you can use all the features of this interface to get instances of your services. In addition, the CreateInstance method allows you to create instances of classes that are not registered in the container, but whose constructor parameters can be resolved from the container.

This class creates a new scope (the _scope field) before resolving any service. This allows you to use even services registered for a scope (for example, using the AddScope method). The scope will be destroyed by the Dispose method. That's why the class implements the IDisposable interface.

Now about getting mocks (the GetMock method). Here we implement the following idea. You can create any mock, but only before the first service is resolved from the container. After that, you will not be able to create new mocks. The reason is that the container creates a service using some specific dependency instances. This means that the service object can store references to instances of these classes. And now it is impossible to replace these references. That's why the mocks created after the first service is resolved are actually useless. And that's why we don't allow them to be created.

All already created mocks are stored in the dictionary _registeredMocks. The _configurationIsFinished field contains information about whether any service has already been resolved or not.

Note that when we create a mock, we remove all entries for this type from the container and replace when with just this one mock. If you need to test code that gets not only one instance, but a collection of objects of this type, this approach may not be enough. In this case, you will have to extend the functionality of this class in a way that suits your needs.

Testing at the project level

Up to this point, we used the dependency container to test the entire application. But there is another option. In our company, the solution contains several sections corresponding to domain areas. Each section can contain several projects (assemblies) - for domain classes, for infrastructure, ... For example:

  • Users.Domain
  • Users.Repository
  • Users.Api

or

  • Orders.Domain
  • Orders.Repository
  • Orders.Api

And each such project provides an extension method for IServiceCollection that registers classes from this project:

    
public static class ContainerConfig
{
    public static void RegisterDomainServices(this IServiceCollection services)
    {
        services.AddScope<ISystem, System>();
        services.AddScope<IService1, Service1>();
        ...
    }
}
    

In the end, our main project just uses all these extension methods.

Suppose we want to create tests at the project level. This means that we only want to test the interaction of classes defined in one particular project. It may seem simple. We create an instance of ServiceCollection, execute our extension method for this instance, and now we are in the same situation as before when we tested the entire application.

But there is a serious difference here. When we tested the entire application, absolutely all classes were registered in our instance of the ServiceCollection class. In a situation with a separate project, this is not the case. This extension method registers only the classes defined in this project. But these classes may depend on interfaces that are not implemented in this project, but are implemented elsewhere.

For example, our class System depends on the interfaces IService1 and IService2. Both of these interfaces are defined in the same project, which contains the System class. But the interface IService1 has its own implementation there, and the interface IService2 does not. It is expected that it will be implemented in some other project, and our application will take it from there.

So how can we test the System class only with classes from the same project? The idea is to force our dependency container to use mocks for interfaces that are not registered. In order to do this, we need a container that can handle the situation of absent dependencies. I used DryIoc. Let's see how we can create the necessary functionality:

    
public class Configurator : IServiceProvider, IDisposable
{
    private readonly AutoMocker _autoMocker = new AutoMocker();
    private readonly IDictionary<Type, Mock> _registeredMocks = new Dictionary<Type, Mock>();
    private readonly IServiceCollection _services;

    private IContainer? _container;
    private IServiceScope? _scope;
    private bool _configurationIsFinished = false;

    public Configurator(IServiceCollection? services = null)
        : this(FillServices(services))
    {
    }

    public Configurator(Action<IServiceCollection> configuration)
    {
        _services = new ServiceCollection();

        configuration?.Invoke(_services);
    }

    private static Action<IServiceCollection> FillServices(IServiceCollection? services)
    {
        return internalServices =>
        {
            if (services != null)
            {
                foreach (var description in services)
                {
                    internalServices.Add(description);
                }
            }
        };
    }

    public void Dispose()
    {
        _scope?.Dispose();

        _container?.Dispose();
    }

    /// <summary>
    /// Creates instance of <typeparamref name="T"/> type using dependency container
    /// to resolve constructor parameters.
    /// </summary>
    /// <typeparam name="T">Type of instance.</typeparam>
    /// <returns>Instance of <typeparamref name="T"/> type.</returns>
    public T CreateInstance<T>()
    {
        PrepareScope();

        return ActivatorUtilities.CreateInstance<T>(_scope!.ServiceProvider);
    }

    /// <summary>
    /// Returns service registered in the container.
    /// </summary>
    /// <param name="serviceType">Service type.</param>
    /// <returns>Instance of a service from the container.</returns>
    public object? GetService(Type serviceType)
    {
        PrepareScope();

        return _scope!.ServiceProvider.GetService(serviceType);
    }

    /// <summary>
    /// Replaces in the dependency container records of <typeparamref name="T"/> type
    /// with a singleton mock and returns the mock.
    /// </summary>
    /// <typeparam name="T">Type of service.</typeparam>
    /// <returns>Mock for the <typeparamref name="T"/> type.</returns>
    /// <exception cref="InvalidOperationException">This method can't be called after
    /// any service is resolved from the container.</exception>
    public Mock<T> GetMock<T>()
        where T : class
    {
        if (_registeredMocks.ContainsKey(typeof(T)))
        {
            return (Mock<T>)_registeredMocks[typeof(T)];
        }

        if (!_configurationIsFinished)
        {
            var mock = new Mock<T>();

            _registeredMocks.Add(typeof(T), mock);

            _services.RemoveAll<T>();
            _services.AddSingleton(mock.Object);

            return mock;
        }
        else
        {
            throw new InvalidOperationException($"You can not create new mock after any service is already resolved (after call of {nameof(CreateInstance)} or {nameof(GetService)})");
        }
    }

    private void PrepareScope()
    {
        if (!_configurationIsFinished)
        {
            _configurationIsFinished = true;

            _container = CreateContainer();

            _scope = _container.BuildServiceProvider().CreateScope();
        }
    }

    private IContainer CreateContainer()
    {
        Rules.DynamicRegistrationProvider dynamicRegistration = (serviceType, serviceKey) =>
        new[]
        {
            new DynamicRegistration(DelegateFactory.Of(_ =>
            {
                if(_registeredMocks.ContainsKey(serviceType))
                {
                    return _registeredMocks[serviceType].Object;
                }

                var mock = _autoMocker.GetMock(serviceType);

                _registeredMocks[serviceType] = mock;

                return mock.Object;
            }))
        };

        var rules = Rules.Default.WithDynamicRegistration(
            dynamicRegistration,
            DynamicRegistrationFlags.Service | DynamicRegistrationFlags.AsFallback);

        var container = new Container(rules);

        container.Populate(_services);

        return DryIocAdapter.WithDependencyInjectionAdapter(container);
    }
}
    

The Configurator class is very similar to the SimpleConfigurator class shown earlier, but it has several important differences. First of all, instead of the Microsoft dependency container, we use DryIoc. For this container, we set the behavior for situations where we need an unregistered dependency:

    
Rules.DynamicRegistrationProvider dynamicRegistration = (serviceType, serviceKey) =>
new[]
{
    new DynamicRegistration(DelegateFactory.Of(_ =>
    {
        if(_registeredMocks.ContainsKey(serviceType))
        {
            return _registeredMocks[serviceType].Object;
        }

        var mock = _autoMocker.GetMock(serviceType);

        _registeredMocks[serviceType] = mock;

        return mock.Object;
    }))
};

var rules = Rules.Default.WithDynamicRegistration(
    dynamicRegistration,
    DynamicRegistrationFlags.Service | DynamicRegistrationFlags.AsFallback);

var container = new Container(rules);
    

In this case, we create a Moq mock and save a link to it. This allows us to get it later for configuration and verification.

Now we can test our system only with classes from the same project:

    
[TestMethod]
public void TestSystem()
{
    using var configurator = new Configurator(service => { services.RegisterDomainServices() });

    var system = configurator.GetRequiredService<System>();

    var service2Mock = configurator.GetMock<IService2>();

    ...
}
    

Of course, we don't have to limit ourselves to just one project. For example, we can test classes from several projects related to the same domain area in this way.

Conclusion

In this article, we discussed how we can use the existing dependency container infrastructure for our tests. Undoubtedly, there are many ways to improve the proposed system. But I hope I have given you a framework that can be useful to you.

P. S. The source code of the examples can be found on GitHub.

No comments:

Post a Comment