Как стать автором
Обновить

Комментарии 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-ов давно есть из коробки, как-то отменяет концепцию приведенного мной кода для юнит тестов?

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

Хорошо, я поясню.

  1. Юнит-тест это то, что тестирует отдельный аспект (допустим вызов) отдельного компонента. Твои тесты вполне имеют право на жизнь, но это совсем не юнит-тесты. У тебя тестируется вся связка регистрации сервисов, создания сервиса, его зависимостей, зависимостей зависимостей и т.д. Это вполне можно сделать частью интеграционных тестов, но не совосем понятно нужно ли это, потому что регистрация и создание объектов в интеграционных тестах и так будут покрываться тестами других вызовов. Вот, посмотри:

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) и отдельно тестируем конструктор(ы). А тестировать "все до кучи" следует уже в интеграционных тестах.

  1. Что касается твоего предпоследнего куска кода. Ты создаешь 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();

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

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

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

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

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

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

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

В google не нашел

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

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации