Как переписать сервис с заданной областью декорированной реализацией? - PullRequest
6 голосов
/ 09 апреля 2019

Я пытаюсь написать интеграционный тест ASP.NET Core 2.2, в котором настройка теста украшает конкретную службу, которая обычно будет доступна API как зависимость.Декоратор дал бы мне некоторые дополнительные возможности, которые мне понадобились бы в моих интеграционных тестах для перехвата вызовов к базовому сервису, но Я не могу правильно оформить обычный сервис в ConfigureTestServices, как мой текущийпрограмма установки выдаст мне:

Исключение типа 'System.InvalidOperationException' произошло в Microsoft.Extensions.DependencyInjection.Abstractions.dll, но не было обработано в коде пользователя

Нет службы длятип 'Foo.Web.BarService' зарегистрирован.

Чтобы воспроизвести это, я только что использовал VS2019 для создания нового проекта ASP.NET Core 2.2 API Foo.Web ...

// In `Startup.cs`:
services.AddScoped<IBarService, BarService>();
public interface IBarService
{
    string GetValue();
}
public class BarService : IBarService
{
    public string GetValue() => "Service Value";
}
[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
    private readonly IBarService barService;

    public ValuesController(IBarService barService)
    {
        this.barService = barService;
    }

    [HttpGet]
    public ActionResult<string> Get()
    {
        return barService.GetValue();
    }
}

... и сопутствующий проект xUnit Foo.Web.Tests I использует WebApplicationfactory<TStartup> ...

public class DecoratedBarService : IBarService
{
    private readonly IBarService innerService;

    public DecoratedBarService(IBarService innerService)
    {
        this.innerService = innerService;
    }

    public string GetValue() => $"{innerService.GetValue()} (decorated)";
}
public class IntegrationTestsFixture : WebApplicationFactory<Startup>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        base.ConfigureWebHost(builder);

        builder.ConfigureTestServices(servicesConfiguration =>
        {
            servicesConfiguration.AddScoped<IBarService>(di
                => new DecoratedBarService(di.GetRequiredService<BarService>()));
        });
    }
}
public class ValuesControllerTests : IClassFixture<IntegrationTestsFixture>
{
    private readonly IntegrationTestsFixture fixture;

    public ValuesControllerTests(IntegrationTestsFixture fixture)
    {
        this.fixture = fixture;
    }

    [Fact]
    public async Task Integration_test_uses_decorator()
    {
        var client = fixture.CreateClient();
        var result = await client.GetAsync("/api/values");
        var data = await result.Content.ReadAsStringAsync();
        result.EnsureSuccessStatusCode();
        Assert.Equal("Service Value (decorated)", data);
    }
}

Такое поведение имеет смысл, или, по крайней мере, я думаю это делает: я полагаю, что маленькая заводская лямбда-функция (di => new DecoratedBarService(...)) в ConfigureTestServices не может получить конкретный BarService из контейнера di, потому что он находится в основном сервисном столбцелекция, а не в службах тестирования.

Как сделать так, чтобы контейнер ASP.NET Core DI по умолчанию предоставлял экземпляры декоратора с исходным конкретным типом в качестве внутренней службы?

ПопыткаРешение 2:

Я пробовал следующее:

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    base.ConfigureWebHost(builder);

    builder.ConfigureTestServices(servicesConfiguration =>
    {
        servicesConfiguration.AddScoped<IBarService>(di
            => new DecoratedBarService(Server.Host.Services.GetRequiredService<BarService>()));
    });            
}

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

Попытка решения 3:

Вместо этого запрашивается IBarService, например:

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    base.ConfigureWebHost(builder);

    builder.ConfigureTestServices(servicesConfiguration =>
    {
        servicesConfiguration.AddScoped<IBarService>(di
            => new DecoratedBarService(Server.Host.Services.GetRequiredService<IBarService>()));
    });            
}

Дает мне другую ошибку:

СистемаИсключениев моем маленьком репрое, как это:

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    base.ConfigureWebHost(builder);

    builder.ConfigureTestServices(servicesConfiguration =>
    {
        servicesConfiguration.AddScoped<IBarService>(di
            => new DecoratedBarService(new BarService()));
    });            
}

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


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

Ответы [ 4 ]

3 голосов
/ 10 апреля 2019

Все остальные ответы были очень полезны:

  • @ ChrisPratt четко объясняет основную проблему и предлагает решение, в котором Startup делает регистрацию услуги virtual, а затем переопределяет это в TestStartup, которое навязывается IWebHostBuilder
  • @ huysentruitw отвечает также, что это ограничение базового контейнера DI по умолчанию
  • @ KirkLarkin предлагает прагматичное решение , где вы регистрируете BarService в Startup, а затем используете , , чтобы полностью перезаписать IBarService регистрацию

И все же я хотел бы предложить еще один ответ.

Другие ответы помогли мне найти правильные термины для Google. Оказывается, существует пакет NuGet "Scrutor" , который добавляет необходимую поддержку декоратора в контейнер DI по умолчанию. Вы можете протестировать это решение самостоятельно , так как оно просто требует:

builder.ConfigureTestServices(servicesConfiguration =>
{
    // Requires "Scrutor" from NuGet:
    servicesConfiguration.Decorate<IBarService, DecoratedBarService>();
});

Упомянутый пакет является открытым исходным кодом (MIT), и вы также можете просто адаптировать только необходимые функции самостоятельно, таким образом отвечает на исходный вопрос в том виде, в каком он был, без внешних зависимостей или изменений чего-либо, кроме теста 1037 * проект :

public class IntegrationTestsFixture : WebApplicationFactory<Startup>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        base.ConfigureWebHost(builder);

        builder.ConfigureTestServices(servicesConfiguration =>
        {
            // The chosen solution here is adapted from the "Scrutor" NuGet package, which
            // is MIT licensed, and can be found at: https://github.com/khellang/Scrutor
            // This solution might need further adaptation for things like open generics...

            var descriptor = servicesConfiguration.Single(s => s.ServiceType == typeof(IBarService));

            servicesConfiguration.AddScoped<IBarService>(di 
                => new DecoratedBarService(GetInstance<IBarService>(di, descriptor)));
        });
    }

    // Method loosely based on Scrutor, MIT licensed: https://github.com/khellang/Scrutor/blob/68787e28376c640589100f974a5b759444d955b3/src/Scrutor/ServiceCollectionExtensions.Decoration.cs#L319
    private static T GetInstance<T>(IServiceProvider provider, ServiceDescriptor descriptor)
    {
        if (descriptor.ImplementationInstance != null)
        {
            return (T)descriptor.ImplementationInstance;
        }

        if (descriptor.ImplementationType != null)
        {
            return (T)ActivatorUtilities.CreateInstance(provider, descriptor.ImplementationType);
        }

        if (descriptor.ImplementationFactory != null)
        {
            return (T)descriptor.ImplementationFactory(provider);
        }

        throw new InvalidOperationException($"Could not create instance for {descriptor.ServiceType}");
    }
}
3 голосов
/ 09 апреля 2019

Здесь есть несколько вещей.Во-первых, когда вы регистрируете сервис с интерфейсом, вы можете только внедрить этот интерфейс.Вы на самом деле говорите: «когда вы видите, IBarService вводит экземпляр BarService».Коллекция сервисов ничего не знает о самой BarService, поэтому вы не можете ввести BarService напрямую.

, что приводит ко второй проблеме.Когда вы добавляете свою новую DecoratedBarService регистрацию, у вас теперь есть две зарегистрированных реализации для IBarService.У него нет возможности узнать, что именно ввести вместо IBarService, так что еще раз: сбой.Некоторые DI-контейнеры имеют специализированную функциональность для этого типа сценария, позволяя указать, когда вводить, а Microsoft.Extensions.DependencyInjection - нет.Если вам действительно нужны эти функции, вы можете вместо этого использовать более продвинутый DI-контейнер, но, учитывая, что это только для тестирования, это может быть ошибкой.

В-третьих, здесь у вас есть круговая зависимость,поскольку DecoratedBarService сам принимает зависимость от IBarService.Опять же, более продвинутый DI-контейнер может справиться с подобными вещами;Microsoft.Extensions.DependencyInjection не может.

Лучше всего здесь использовать унаследованный класс TestStartup и выделить эту регистрацию зависимостей в защищенный виртуальный метод, который вы можете переопределить.В вашем Startup классе:

protected virtual void AddBarService(IServiceCollection services)
{
    services.AddScoped<IBarService, BarService>();
}

Затем, где вы делали регистрацию, вместо этого вызовите этот метод:

AddBarService(services);

Далее, в вашем тестовом проекте создайте TestStartupи наследовать от вашего проекта SUT Startup.Там переопределите этот метод:

public class TestStartup : Startup
{
    protected override void AddBarService(IServiceCollection services)
    {
        services.AddScoped(_ => new DecoratedBarService(new BarService()));
    }
}

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

services.AddScoped(p =>
{
    var dep = p.GetRequiredService<Dependency>();
    return new DecoratedBarService(new BarService(dep));
}

Наконец, скажите своему WebApplicationFactory, чтобы использовать этот TestStartup класс.Это нужно будет сделать с помощью метода UseStartup компоновщика, а не универсального параметра типа WebApplicationFactory.Этот параметр общего типа соответствует точке входа приложения (т. Е. Вашей SUT), а не тому, какой класс запуска фактически используется.

builder.UseStartup<TestStartup>();
3 голосов
/ 09 апреля 2019

Это похоже на ограничение метода servicesConfiguration.AddXxx, который сначала удалит тип из IServiceProvider, переданного в лямбду.

Это можно проверить, изменив servicesConfiguration.AddScoped<IBarService>(...) на servicesConfiguration.TryAddScoped<IBarService>(...), и вы увидите, что во время теста вызывается исходный BarService.GetValue.

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

2 голосов
/ 09 апреля 2019

Существует простая альтернатива этому, которая требует регистрации BarService в контейнере DI и последующего разрешения при выполнении декорирования. Все, что требуется, это обновить ConfigureTestServices, чтобы сначала зарегистрировать BarService, а затем использовать экземпляр IServiceProvider, переданный в ConfigureTestServices, для его разрешения. Вот полный пример:

builder.ConfigureTestServices(servicesConfiguration =>
{
    servicesConfiguration.AddScoped<BarService>();

    servicesConfiguration.AddScoped<IBarService>(di =>
        new DecoratedBarService(di.GetRequiredService<BarService>()));
});

Обратите внимание, что это не требует каких-либо изменений в проекте SUT. Вызов AddScoped<IBarService> здесь фактически перекрывает тот, который предоставляется в классе Startup.

...