Pull to refresh

Тестирование дерева зависимостей

Level of difficultyMedium
Reading time12 min
Views2.4K
Original author: Ivan Iakimov

Сегодня использование контейнеров зависимостей является стандартной практикой. Ваши классы принимают в конструкторе экземпляры других классов, те в свою очередь зависят от третьих классов и т. д. И сборкой всего этого управляет контейнер зависимостей.

Эта система имеет свою цену. Например, во время тестирования вам приходится как‑то создавать экземпляры всех принимаемых конструктором параметров, чтобы протестировать ваш класс. Для этого можно использовать что‑то типа Moq. Но в таком случае встаёт проблема изменений класса. Если вы хотите добавить или удалить какой‑нибудь параметр из конструктора, вам приходится менять и тесты, даже если на них данный параметр никак не влияет.

Есть и ещё одна задача, которую хотелось бы решать при тестировании. Предположим, что вы хотите проверить работу не одного изолированного класса, а совместную работу классов некоторой части вашей системы. Ваш контейнер зависимостей строит для вас целое дерево из экземпляров различных классов. И вам хочется протестировать его работу целиком. Давайте посмотрим, как это можно сделать, какие трудности встречаются на этом пути, и как их можно обойти.


Устойчивость к изменениям конструктора

Пусть у нас есть некий класс, который мы хотим тестировать:

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

    ...
}

Обычно тесты для него пишутся в следующей манере:

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

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

    ...
}

Но вот пришло время, когда мне потребовалось добавить в класс System логирование:

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

    ...
}

Теперь мои тесты даже не компилируются. Мне нужно пойти во все места, где я создаю экземпляры моего класса System и исправить там код:

[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
    );

    ...
}

Несомненно, чтобы сократить работу, я могу вынести создание экземпляра класса в отдельный метод. Тогда мне не придётся проделывать эти изменения в каждом тесте:

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();

    ...
}

Но и у этого подхода есть недостатки. Мне всё же пришлось создать заглушку для ILogger, хотя в тестах она мне совершенно не нужна. Я использую её только для передачи в конструктор моего класса.

К счастью, существует AutoMocker. Вы просто создаёте экземпляр вашего класса с помощью вызова CreateInstance:

private AutoMocker _autoMocker = new();

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

    ...
}

Данный метод способен создавать экземпляры любых классов, даже sealed. Он работает подобно контейнеру зависимостей, анализируя конструктор и создавая заглушки для его параметров.

В любой момент вы можете получить нужную вам заглушку, чтобы установить её поведение или проверить совершённые на ней вызовы:

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

Кроме того, если вы хотите подсунуть вашему классу не заглушку Moq, а вашу собственную реализацию, то до вызова CreateInstance можно сделать и это:

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

Красота! Теперь можно смело менять сигнатуру конструктора не опасаясь, что придётся править тесты в тысяче мест.

Впрочем, тот факт, что тесты продолжают компилироваться, не означает, что они будут продолжать проходить после внесения изменений в класс. С другой стороны, как неоднократно говорилось, хорошие тесты должны проверять контракт класса, а не его внутреннюю реализацию. Если контракт не поменялся, то и тесты должны продолжать проходить. Если же поменялся контракт, то и изменения тестов не избежать.

Правда до того, как мы начали использовать AutoMocker мы сразу видели, какие тесты затронуло наше изменение конструктора и могли перезапустить только их. Теперь, возможно, придётся перезапустить все тесты, чтобы проверить ваши изменения, если у вас нет соглашения о том, куда помещать все тесты, касающиеся одного класса. Но тут уж каждый решает для себя, какой подход выбирать.

А мы движемся дальше.

Тестирование с зависимостями

Один мой коллега предложил пойти дальше. На самом деле в нашем приложении мы всё равно строим контейнер зависимостей, в котором регистрируем все наши классы и интерфейсы. Так почему бы не заставить наши тесты брать экземпляры классов для тестирования из этого контейнера. В таком случае мы будем тестировать именно то дерево объектов, которое используется в реальной работе. Это было бы полезно для интеграционных тестов.

Например, наш код регистрации зависимостей имеет вид:

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

Мы выносим эту регистрацию в отдельный метод:

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

и используем его для регистрации наших сервисов:

ServicesConfiguration.RegisterEverything(services);

Но теперь мы используем этот код и в тестах:

[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>();

    ...
}

И даже если ваш класс не зарегистрирован в контейнере зависимостей, а вы просто хотите взять оттуда параметры для его конструктора, это можно сделать так:

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

Естественно, может потребоваться внести некоторые изменения в зарегистрированные сервисы. Например, вы можете захотеть подменить строки подключения к базам данных, если вы не используете для их получения IConfiguration:

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

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

В случае же использования IConfiguration, вы можете просто создать свою конфигурацию, например, с помощью хранилища в оперативной памяти:

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

var configuration = builder.Build();

Но даже в этом случае отказываться от заглушек Moq полностью не хочется. Дело в том, что даже в случае интеграционных тестов существуют внешние зависимости, которые вы не контролируете. Если вашу базу данных вы ещё можете создавать и управлять ей, то существуют внешние сервисы, к которым ваш код может обращаться, например, через HTTP-запросы. Такие внешние запросы всё ещё требуется имитировать с помощью заглушек.

Для их поддержки придётся написать немного кода. Я вынес всю логику, касающуюся получения экземпляров сервисов, а так же для управления заглушками, в отдельный класс:

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();
        }
    }
}

Давайте рассмотрим этот класс более подробно.

Данный класс реализует интерфейс IServiceProvider, поэтому вы можете использовать все возможности этого интерфейса для получения экземпляров сервисов. Кроме того, метод CreateInstance позволяет вам создавать экземпляры классов, которые не зарегистрированы в контейнере, но чьи параметры конструктора могут быть получены из контейнера.

Данный класс перед получением любого сервиса создаёт область видимости (поле _scope). Это позволяет получать даже сервисы, зарегистрированные для одной области видимости (например, с помощью метода AddScope). Область видимости уничтожается в методе Dispose. Именно для этого класс и реализует интерфейс IDisposable.

А теперь про получение заглушек (метод GetMock). Здесь реализуется следующая идея. Заглушку для любого сервиса можно создать до тех пор, пока вы не запросили у контейнера первый сервис. После этого создавать новые заглушки нельзя. Причина в том, что контейнер создаст сервис, используя определённые экземпляры классов зависимостей. Т. е. объект сервиса может иметь ссылки на экземпляры этих классов. И заменить эти экземпляры на заглушки уже никак не получится. Поэтому заглушки, созданные после получения сервиса, фактически являются бесполезными. Поэтому мы и не позволяем создавать их.

Все уже созданные нами заглушки хранятся в словаре _registeredMocks. Поле _configurationIsFinished хранит информацию о том, запросили ли мы хоть один сервис или ещё нет.

Обратите внимание, что при создании заглушки мы удаляем из контейнера все записи для данного типа и добавляем на их место только одну эту заглушку. Если вам потребуется тестировать код, который принимает не один объект данного типа, а целую коллекцию, такой подход может оказаться недостаточным. В этом случае функциональность нашего класса придётся расширить так, как вам это требуется.

Тестирование уровня проекта

До сих пор мы использовали наш контейнер зависимостей для того, чтобы тестировать всё приложение целиком. Но есть и ещё один вариант тестирования. У нас весь solution приложения разбит на несколько секций в соответствие с доменными областями. Каждая такая секция может содержать несколько проектов (сборок) - для классов доменной области, для классов инфраструктуры, и т. д. Например:

  • Users.Domain

  • Users.Repository

  • Users.Api

или

  • Orders.Domain

  • Orders.Repository

  • Orders.Api

И каждый проект предоставляет метод расширения для IServiceCollection, регистрирующий описанные в нём классы:

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

В итоге главный проект приложения только использует у себя эти методы расширения.

Предположим, что мы хотим создать тесты уровня проекта. Т. е. мы хотим протестировать взаимодействие только тех классов, которые описаны в данном проекте. Вроде бы всё просто. Создаём экземпляр ServiceCollection, выполняем на нём наш метод расширения, и вот мы в той же ситуации, что и при тестировании всего приложения.

Но у этой ситуации есть серьёзное отличие. Когда мы тестировали всё приложение, в нашем экземпляре ServiceCollection были зарегистрированы абсолютно все классы, требуемые для получения любого экземпляра сервиса. В случае же отдельного проекта это не так. Его метод расширения, регистрирующий сервисы, регистрирует только то, что находится в нём самом. Но описанные в проекте классы вполне могут ссылаться на интерфейсы, которые не реализованы в данном проекте, а реализуются в других местах.

Например, наш класс System зависит от интерфейсов IService1 и IService2. Оба этих интерфейса объявлены в том же проекте, что и класс System. Но интерфейс IService1 имеет в этом проекте реализацию Service1, а интерфейс IService2 не имеет такой реализации. Ожидается, что он будет реализован в другом проекте и наше приложение будет брать его оттуда.

Как же нам тестировать класс System только с классами его проекта? Идея заключается в том, чтобы наш контейнер зависимостей использовал заглушки в случае, если требуемый интерфейс в нём не зарегистрирован. Для этого нам потребуется такой контейнер зависимостей, который мог бы обрабатывать ситуацию отсутствия зарегистрированной зависимости. Я использовал DryIoc. Давайте посмотрим, как с его помощью построить требуемую нам функциональность:

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);
    }
}

Класс Configurator похож на представленный ранее класс SimpleConfigurator, но имеет ряд важных отличий. Во-первых, мы не используем контейнер зависимостей от Microsoft, вместо него используется DryIoc. Для него настраивается поведение на случай, если требуется некоторая незарегистрированная в нём зависимость:

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);

В таком случае мы создаём Moq-заглушку и сохраняем ссылку на неё, чтобы позднее её можно было конфигурировать или верифицировать.

Теперь мы можем тестировать нашу систему вместе с классами из её проекта:

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

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

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

    ...
}

Конечно же, можно не ограничиваться одним проектом. Таким образом можно тестировать, например, классы из группу проектов, относящихся к одной доменной области.

Заключение

В этой статье мы обсудили, как можно использовать имеющуюся инфраструктуру контейнера зависимостей для наших тестов. Несомненно, здесь присутствует множество возможностей для улучшений. Но, надеюсь, я предоставил вам базу, которая будет вам полезной.

P.S. Исходный код примеров вы можете найти на GitHub.

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
Total votes 1: ↑0 and ↓1-1
Comments2

Articles