Юнит-тесты на внедрение зависимостей Microsoft.Extensions.DependencyInjection

—

от автора

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


ссылка на оригинал статьи https://habr.com/ru/articles/703536/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *