C постепенным переходом проектов на .NET Core фреймворки все большую популярность набирает стандартная реализация внедрения зависимостей от Microsoft. На мой взгляд эта реализация все еще уступает Autofac, но в этой статье речь пойдет не об этом. При использовании любого фреймворка для внедрения зависимостей рано или поздно разработчики сталкиваются с проблемой забытой или неправильной регистрации зависимостей, что влечет за собой ошибку в рантайме приложения.

Хорошо, если разработчик, привнеся ошибку в код, самостоятельно ее обнаружил во время отладки, а если нет? Если ошибка ускользнула от разработчика, QA-инженера, прошла через все стадии доставки изменений кода до пользователей и оказалась в production? В этом случае есть вопросы к контроля качества в команде, однако, нельзя отрицать тот факт, что работа с зависимостями в .NET приложении несет в себе большой регрессионный риск.

В основе юнит-тестирования внедрения зависимостей лежит простая идея - отлавливать проблемы с DI в приложении еще на этапе автоматического запуска юнит-тестов, настроенного в вашем ci/cd пайплайне. Дальнейшее повествование будет вестись в контексте ASP.NET Core приложения.

Пишем юнит тесты

Перед написанием юнит тестов определю некоторые требования к ним. Тесты должны:

  • Поддерживать автоматический assembly-сканнинг всех точек входа в приложение, в случае ASP.NET Core это будут MVC контроллеры.

  • Иметь базовый класс или набор extension-методов для переиспользования в множестве проектов.

  • Поддерживать проверку внедрения зависимостей для BackgroundServices, слушателей ServiceBus или других кастомных классов в Console Applications.

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

Целевым фреймворком для указанного ниже кода является .NET 5, однако, код легко адаптировать и для .NET 6+. Начнем с основы и напишем базовый класс для тестирования зависимостей API контроллеров:

public abstract class ApiDependencyInjectionTestBase<TStartup> where TStartup : class
{
    protected IEnumerable<object> ResolveEntryPoints()
    {
        var host = WebHost.CreateDefaultBuilder()
           .UseStartup<TStartup>()
           .Build();

        var serviceProvider = host.Services;
        using var serviceScope = serviceProvider.CreateScope();

        var baseClassType = typeof(ControllerBase);
        var typesToResolve = typeof(TStartup).Assembly
            .GetTypes()
            .Where(x => x.IsClass && !x.IsInterface && !x.IsAbstract && baseClassType.IsAssignableFrom(x))
            .ToList();

        foreach (var typeToResolve in typesToResolve)
        {
            yield return ActivatorUtilities.CreateInstance(serviceProvider, typeToResolve);
        }
    }
}

Базовый generic-класс содержит один единственный метод ResolveEntryPoints , который внутри себя создает WebHost на основе заданного Startup класса, с помощью рефлексии ищет всех наследников базового класса ControllerBase в сборке и пытается их внедрить вместе со всеми зависимостями. В случае, если какая-нибудь из зависимостей не будет должным образом зарегистрирована, при попытке вызова ActivatorUtilities.CreateInstance() будет выброшено соответствующее исключение. В реализации юнит теста нам остается только использовать Startup класс в качестве generic-типа и проверить, что список внедренных контроллеров не пустой и действительно содержит объекты:

public class ApiDependencyInjectionTests : ApiDependencyInjectionTestBase<Startup>
{
    [Fact]
    public void DependenciesRegistrationTest()
    {
        var instances = ResolveEntryPoints();

        Assert.NotEmpty(instances);
        foreach (var instance in instances)
        {
            Assert.NotNull(instance);
        }
    }
}

В своем проекте я поставляю базовый ApiDependencyInjectionTestBase класс в NuGet пакете и пишу простой тест на внедрение зависимостей в каждом MVC-приложении.

Однако, кроме ASP.NET приложений конкретно в моем проекте используются еще и Console Applications, которые хостят разного рода фоновые задачи или обработчики сообщений от брокеров. Для этих целей напишем чуть более сложный базовый класс WebJobsDependencyInjectionTestBase:

 public abstract class WebJobsDependencyInjectionTestBase
{
    private IServiceProvider serviceProvider { get; set; }

    protected void SetUpServiceProvider(
        string appSettingsPath,
        Action<IConfiguration> setConfiguration,
        Action<IServiceCollection> configureServices)
    {
        var configuration = new ConfigurationBuilder()
           .AddJsonFile(appSettingsPath)
           .Build();

        setConfiguration(configuration);

        var host = new HostBuilder()
            .ConfigureHostConfiguration(configBuilder =>
            {
                configBuilder.AddConfiguration(configuration);
            })
            .ConfigureServices(configureServices)
            .Build();

        serviceProvider = host.Services;
    }

    protected IEnumerable<object> ResolveEntryPoints(Assembly assembly, params Type[] additionalTypesToResolve)
    {
        using var serviceScope = serviceProvider.CreateScope();

        var messageHandlerInterface = typeof(IMessageHandler);
        var backgroundServiceBaseClass = typeof(BackgroundService);
        var typesToResolve = assembly
            .GetTypes()
            .Where(x => x.IsClass && !x.IsInterface && !x.IsAbstract &&
            (messageHandlerInterface.IsAssignableFrom(x) || backgroundServiceBaseClass.IsAssignableFrom(x)))
            .ToList();
        typesToResolve.AddRange(additionalTypesToResolve);

        foreach (var typeToResolve in typesToResolve)
        {
            yield return ActivatorUtilities.CreateInstance(serviceProvider, typeToResolve);
        }
    }
}

Поскольку конфигурирование ServiceProvider в случае отсутствия Startup класса является чуть более сложной задачей и требует дополнительных параметров, выделим отдельный метод SetUpServiceProvider. В этот раз нам пришлось отдельно создавать конфигурацию приложения с помощью пути к файлу настроек appsettings.json и использовать Action параметры для конфигурирования ServiceProvider. Метод ResolveEntryPoints также усложнился и использует для Assembly-сканнинга интерфейс IMessageHandler и базовый класс BackgroundService . Кроме этого добавлена возможность принимать кастомный список типов, которые могут использоваться в конкретном приложении как точки входа.

Вот так может выглядеть реализация теста:

public class WebJobsDependencyInjectionTests : WebJobsDependencyInjectionTestBase
{
    [Fact]
    public void DependenciesRegistrationTest()
    {
        SetUpServiceProvider(appSettingsPath: "appsettings.json",
            configuration => Program.Configuration = configuration,
            services => Program.ConfigureServices(services));

        var instances = ResolveEntryPoints(typeof(MesageHandlerWithDependency).Assembly, typeof(CustomClassWithDependency));

        Assert.NotEmpty(instances);
        foreach (var instance in instances)
        {
            Assert.NotNull(instance);
        }
    }
}

При этом отмечу, что в Program класс необходимо сделать публичным, добавить метод public static ServiceProvider ConfigureServices(IServiceCollection services) и свойство public static IConfiguration Configuration { get; set; }.В метод ResolveEntryPoints необходимо передать Assembly для сканирования, а также при необходимости список кастомных классов для резолва.

Заключение

Юнит-тесты на внедрение зависимостей могут покрыть большую часть возможных проблем с зависимостями. Приведенная реализация тестов является рабочим решением, но стоит помнить, что в реальных проектах часто необходимо проверять дополнительные зависимости, например, если вы используете FromServices атрибуты, разного рода Middlewares, профили Automapper, обработчики MediatR и т.д.

C демонстрационным проектом и тестами можно ознакомиться на GitHub

Комментарии (11)


  1. nronnie
    06.12.2022 01:45
    -1

    C постепенным переходом проектов на .NET Core

    Как спалось? Почти все уже с .NET Core на .NET 5/6 перешли. :D

    Что по коду это какой-то ужас. Шаблон для background worker-ов давно есть из коробки, зачем что-то выдумывать. Сделай 'dotnet new worker' и посмотри как оно по-нормальному делается. А с ServiceScope в последнем куске кода вообще какая-то жесть.


    1. spacedmi Автор
      06.12.2022 16:53

      Сначала вообще не хотел на комментарий отвечать, т.к. он переполнен пассивной агрессией. Но все таки отвечу.

      ".NET Core фреймворки" упомянуты в смысле "Фреймворки, основанные на .NET Core", далее в статье я указываю, что данный пример нацелен на .NET 5, но путем нехитрых манипуляций можно адаптировать под .NET 6. Достаточно современно для Вас?

      То, что шаблон background worker-ов давно есть из коробки, как-то отменяет концепцию приведенного мной кода для юнит тестов?

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


  1. gdt
    06.12.2022 11:38

    Интересный подход. Если б только был способ провалидировать зависимости при сборке контейнера.


    1. ilya-chumakov
      06.12.2022 12:50

      1. gdt
        06.12.2022 13:03

        Это был намёк :) Просто мы у себя валидируем, интересно в чем преимущество такого юнит-теста, может нам тоже надо так делать


        1. spacedmi Автор
          06.12.2022 16:41

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

          Кроме этого находить проблемы с зависимостями в рантайме дороже чем на этапе юнит-тестов.


          1. ilya-chumakov
            06.12.2022 20:07

            Как раз воспользоваться options.ValidateOnBuild - максимально дешево, один раз написать тест на сборку контейнера. Все интеграционные тесты через TestHost также свалятся, если валидация контейнера не прошла. Да и ваш SetUpServiceProvider свалится.

            Эта валидация не покрывает многие кейсы, часть из них я упомянул в заключении.

            Можете пример привести, когда контейнер пройдет валидацию, но не пройдет ваш тест? Т.е. в чем практическая польза предложенного метода. Проверка зависимостей контроллеров - не принимается, поскольку из коробки есть

               services.AddControllers().AddControllersAsServices();
            

            После которого зависимости контроллеров будут валидироваться самим контейнером.


            1. spacedmi Автор
              06.12.2022 20:32

              Самый простой пример: инъекция зависимости с [FromServices] атрибутом, ваш подход не сможет отловить ошибку. Я же могу гибко настраивать тесты и модифицирую assembly сканнинг для поиска FromServices аргументов


          1. gdt
            07.12.2022 17:46

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

            Однако тут имеет смысл сравнить, как часто запускается приложение в сравнении с тем, как часто запускаются тесты. Например, у нас тесты запускаются на каждый коммит, в то время как в обычном сценарии использования приложение запускается через автозапуск и висит до выключения машины - тут уже можно поспорить, что дороже :) К тому же, валидацию можно включать только в дебажных сборках, чтобы девелоперы сразу на своих машинах и правили эти проблемы.

            Конечно, есть corner-case'ы, такие как например request handler'ы Mediatr'а в совокупности с плагинной системой, которые не всегда проявляются - например, в 1% случаев, когда отправляешь запрос, его хэндлер ещё не успел зарегистрироваться в контейнере - но они должны решаться системно. В остальном я не совсем понимаю, как можно например сделать фичу, пройти через qa, а потом выяснить, что регистрацию забыл.

            Должен добавить что у нас MS DI + SimpleInjector, у которого есть своя валидация, так что your experience may vary :)


  1. DonAlPAtino
    07.12.2022 12:35

    Не бейти пожалуйста сильно :-) А есть аналог для Java Spring'а? А то постоянно где-нибудь забываешь @autowired поставить, а потом долго ошибки в рантайм ловишь. Учитель на курсах на такой вопрос ответил "нам не надо" и "с опытом лажать перестанешь". Ну это как-то не очень по мне...

    В google не нашел


    1. spacedmi Автор
      07.12.2022 16:19

      К сожалению, про Java Spring не смогу подсказать