Комментарии 13
C постепенным переходом проектов на .NET Core
Как спалось? Почти все уже с .NET Core на .NET 5/6 перешли. :D
Что по коду это какой-то ужас. Шаблон для background worker-ов давно есть из коробки, зачем что-то выдумывать. Сделай 'dotnet new worker' и посмотри как оно по-нормальному делается. А с ServiceScope в последнем куске кода вообще какая-то жесть.
Сначала вообще не хотел на комментарий отвечать, т.к. он переполнен пассивной агрессией. Но все таки отвечу.
".NET Core фреймворки" упомянуты в смысле "Фреймворки, основанные на .NET Core", далее в статье я указываю, что данный пример нацелен на .NET 5, но путем нехитрых манипуляций можно адаптировать под .NET 6. Достаточно современно для Вас?
То, что шаблон background worker-ов давно есть из коробки, как-то отменяет концепцию приведенного мной кода для юнит тестов?
Ну а про "жесть в коде" мне прокомментировать нечего, с радостью бы посмотрел на Вашу реализацию и набрался идей как сделать лучше
Хорошо, я поясню.
Юнит-тест это то, что тестирует отдельный аспект (допустим вызов) отдельного компонента. Твои тесты вполне имеют право на жизнь, но это совсем не юнит-тесты. У тебя тестируется вся связка регистрации сервисов, создания сервиса, его зависимостей, зависимостей зависимостей и т.д. Это вполне можно сделать частью интеграционных тестов, но не совосем понятно нужно ли это, потому что регистрация и создание объектов в интеграционных тестах и так будут покрываться тестами других вызовов. Вот, посмотри:
using FakeItEasy;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Foo;
public void ConfigureServices(IServiceCollection services)
{
services.AddTransient<Foo>();
services.AddTransient<IBar, Bar>();
}
public class Foo
{
public IBar Bar { get; init; };
public Foo(IBar bar) => _bar = bar ?? throw new ArgumentNullException(nameof(bar));
}
public interface IBar { }
public class Bar: IBar { }
public class Tests
{
///<summary>
/// Это настоящий unit тест.
///</summary>
[Fact]
public void ConfigureServices_should_register_Foo()
{
ServiceCollection services = new();
Startup sut = new();
sut.ConfigureServices(services);
services.Should().Contain(sd => sd.ServiceType == typeof(Foo));
}
///<summary>
/// И это unit тест.
///</summary>
[Fact]
public void ConfigureServices_should_register_IBar()
{
ServiceCollection services = new();
Startup sut = new();
sut.ConfigureServices(services);
services.Should().Contain(sd => sd.ServiceType == typeof(IBar));
}
///<summary>
/// И вот это unit тест.
///</summary>
[Fact]
public void Foo_constructor_should_check_null_bar()
{
FluentActions.Invoking(() => new Foo(null!)).Should().Throw<ArgumentNullException>()
.Which.ParamName.Should().Be("bar");
}
///<summary>
/// И вот еще один unit тест.
///</summary>
[Fact]
public void Foo_constructor_should_initialize_Bar()
{
var bar = A.Fake<IBar>();
Foo sut = new(bar);
sut.Bar.Should().BeSameAs(bar);
}
}
Тут мы отдельно тестируем регистрацию (т.е. метод ConfigureServices
) и отдельно тестируем конструктор(ы). А тестировать "все до кучи" следует уже в интеграционных тестах.
Что касается твоего предпоследнего куска кода. Ты создаешь scope:
using var serviceScope = serviceProvider.CreateScope();
что абсолютно правильно. Но затем, почему-то создаешь объекты не в нем, а используя исходный IServiceProvider
:
yield return ActivatorUtilities.CreateInstance(serviceProvider, typeToResolve);
Если у тебя какая-то зависимось зарегистрирована как scoped, то получишь "false negative" в виде неожиданного exception. Правильно было бы:
yield return ActivatorUtilities.CreateInstance(serviceScope.ServiceProvider, typeToResolve);
И даже тогда остается огрех. Из-за using serviceScope = ...
объекты созданные в нем будут все еще доступны после того как serviceScope.Dispose()
вызовет Dispose()
для всех созданных через него объектов. В твоем случае это никакой роли не играет (ты ничего больше у этих объектов не вызываешь, к тому же из-за конструкции yield
Dispose()
вызовется только после перечисления всей коллекции), но все равно подобный код лучше не писать, т.к. если не здесь, то в другом месте можно наступить на те еще грабли. Я уверен, что все хоть однажды наступали на очень похожие грабли с EF и LINQ:
public IEnumerable<Person> GetAdults()
{
using MyDbContext context = new();
return context.People.Where(p => p.Age > 18);
}
На первый взгляд все нормально, но что будет на самом деле, я думаю, тут объяснять не надо.
PS. Блок кода под спойлер никак не помещается (если писать в режиме markdown)?
Интересный подход. Если б только был способ провалидировать зависимости при сборке контейнера.
Это был намёк :) Просто мы у себя валидируем, интересно в чем преимущество такого юнит-теста, может нам тоже надо так делать
Эта валидация не покрывает многие кейсы, часть из них я упомянул в заключении. И не имеет гибкой настройки, чтобы вручную дописать валидацию тех зависимостей, которые не покрыты.
Кроме этого находить проблемы с зависимостями в рантайме дороже чем на этапе юнит-тестов.
Как раз воспользоваться options.ValidateOnBuild - максимально дешево, один раз написать тест на сборку контейнера. Все интеграционные тесты через TestHost также свалятся, если валидация контейнера не прошла. Да и ваш SetUpServiceProvider свалится.
Эта валидация не покрывает многие кейсы, часть из них я упомянул в заключении.
Можете пример привести, когда контейнер пройдет валидацию, но не пройдет ваш тест? Т.е. в чем практическая польза предложенного метода. Проверка зависимостей контроллеров - не принимается, поскольку из коробки есть
services.AddControllers().AddControllersAsServices();
После которого зависимости контроллеров будут валидироваться самим контейнером.
На самом деле я понимаю откуда берутся такие тесты - заводится баг, выясняется что какая-то регистрация отсутствует, баг фиксится, на него пишутся тесты чтобы такого впредь не повторялось. Это хорошая практика.
Однако тут имеет смысл сравнить, как часто запускается приложение в сравнении с тем, как часто запускаются тесты. Например, у нас тесты запускаются на каждый коммит, в то время как в обычном сценарии использования приложение запускается через автозапуск и висит до выключения машины - тут уже можно поспорить, что дороже :) К тому же, валидацию можно включать только в дебажных сборках, чтобы девелоперы сразу на своих машинах и правили эти проблемы.
Конечно, есть corner-case'ы, такие как например request handler'ы Mediatr'а в совокупности с плагинной системой, которые не всегда проявляются - например, в 1% случаев, когда отправляешь запрос, его хэндлер ещё не успел зарегистрироваться в контейнере - но они должны решаться системно. В остальном я не совсем понимаю, как можно например сделать фичу, пройти через qa, а потом выяснить, что регистрацию забыл.
Должен добавить что у нас MS DI + SimpleInjector, у которого есть своя валидация, так что your experience may vary :)
Не бейти пожалуйста сильно :-) А есть аналог для Java Spring'а? А то постоянно где-нибудь забываешь @autowired поставить, а потом долго ошибки в рантайм ловишь. Учитель на курсах на такой вопрос ответил "нам не надо" и "с опытом лажать перестанешь". Ну это как-то не очень по мне...
В google не нашел
Юнит-тесты на внедрение зависимостей Microsoft.Extensions.DependencyInjection