Как можно использовать или смоделировать IWebJobsBuilder для проведения интеграционного теста моей функции Azure v2? - PullRequest
1 голос
/ 23 марта 2019

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

public sealed class CreateAccountFunction
{
    private readonly IAccountWorkflow m_accountWorkflow;

    private readonly ILogger<CreateAccountFunction> m_logger;

    private readonly IMapper m_mapper;

    public CreateAccountFunction(ILoggerFactory loggerFactory, IMapper mapper, IAccountWorkflow accountWorkflow)
    {
        m_logger = loggerFactory.CreateLogger<CreateAccountFunction>();
        m_mapper = mapper;
        m_accountWorkflow = accountWorkflow;
    }

    [FunctionName("CreateAccount")]
    public async Task<IActionResult> Run(
            [HttpTrigger(
                AuthorizationLevel.Function,
                "post",
                Route = "v1/accounts/"
            )]
            HttpRequest httpRequest)
    {
        //   Creates the account.
    }
}

Мой класс Startup содержит следующее:

public sealed class Startup : IWebJobsStartup
{
    public void Configure(IWebJobsBuilder webJobsBuilder)
    {
        webJobsBuilder.Services.AddLogging(loggingBuilder =>
        {
            loggingBuilder.SetMinimumLevel(LogLevel.Debug);
        });

        var mapperConfiguration = new MapperConfiguration(cfg => cfg.AddProfile(new ContractProfile()));
            webJobsBuilder.Services.AddSingleton(mapperConfiguration.CreateMapper());

        webJobsBuilder.Services.AddTransient<IAccountWorkflow, AccountWorkflow>();
    }
}

Теперь я хотел бы провести интеграционные тесты функции Azure.

public class CreateAccountFunctionTests
{
    private readonly CreateAccountFunction m_creationAccountFunction;


    public CreateAccountFunctionTests()
    {
        // --> How can I reuse the Startup and IWebJobsBuilder <--
        m_creationAccountFunction = new CreateAccountFunction(? ? ?);
    }

    [Fact]
    public void TestSomething()
    {
        // Arrange.
        HttpRequest httpRequest = /* builds an instance of HttpRequest */

        // Act.
        var result = m_creationAccountFunction.Run(httpRequest);

        // Assert.
        // Asserts the Status Code.
    }
}

Вопрос

Похоже, что многие вещи для инъекций обрабатываются IWebJobsBuilder.

Как использовать это для проведения интеграционных тестов моих функций Azure?

Я ищу решение, которое минимизирует необходимость создания пользовательского кода и максимально использует существующую инфраструктуру.

1 Ответ

1 голос
/ 24 марта 2019

Я просмотрел код хоста Azure Function и нашел этот фрагмент кода в файле Program.cs:

var host = new HostBuilder()
                .SetAzureFunctionsEnvironment()
                .ConfigureLogging(b =>
                {
                    b.SetMinimumLevel(LogLevel.Information);
                    b.AddConsole();
                })
                .AddScriptHost(options, webJobsBuilder =>
                {
                    webJobsBuilder.AddAzureStorageCoreServices();
                })
                .UseConsoleLifetime()
                .Build();

Меня заинтересовал компонент расширения AddScriptHost(), который делает доступным экземпляр webJobsBuilder (реализацию IWebJobsBuilder).

Зная это, я создал следующий метод, который создает простой экземпляр IHost и использует мой существующий класс Startup, который содержит все внедренные службы:

/// <summary>
/// Builds an instance of the specified <typeparamref name="TFunctionType"/>
/// with the services defined in the <paramref name="startup"/> instance.
/// </summary>
/// <typeparam name="TFunctionType"></typeparam>
/// <param name="startup"></param>
/// <returns></returns>
/// <exception cref="ArgumentNullException">
/// Thrown if:
/// - The <paramref name="startup" /> instance is not specified.
/// </exception>
public static TFunctionType Instanciate<TFunctionType>(Startup startup)
{
    Argument.ThrowIfIsNull(startup, nameof(startup));

    // --> Builds an IHost with all the services registered in the Startup.
    IHost host = new HostBuilder().ConfigureWebJobs(startup.Configure).Build();

    return Instanciate<TFunctionType>(host);
}

Метод Instanciate<TFunctionType> ищет конструктор TFunctionType и извлекает все службы из экземпляра IHost:

/// <summary>
/// Instanciates the specified <typeparamref name="TFunctionType"></typeparamref>.
/// </summary>
/// <typeparam name="TFunctionType"></typeparam>
/// <param name="host"></param>
/// <returns></returns>
private static TFunctionType Instanciate<TFunctionType>(IHost host)
{
    Type type = typeof(TFunctionType);

    // --> This part could be better...
    ConstructorInfo contructorInfo = type.GetConstructors().FirstOrDefault();

    ParameterInfo[] parametersInfo = contructorInfo.GetParameters();

    object[] parameters = LookupServiceInstances(host, parametersInfo);

    return (TFunctionType) Activator.CreateInstance(type, parameters);
}

/// <summary>
/// Gets all the parameters instances from the host's services.
/// </summary>
/// <param name="host"></param>
/// <param name="parametersInfo"></param>
/// <returns></returns>
private static object[] LookupServiceInstances(IHost host, IReadOnlyList<ParameterInfo> parametersInfo)
{
    return parametersInfo.Select(p => host.Services.GetService(p.ParameterType))
                         .ToArray();
}

Я поместил эти методы в HostHelper класс. Теперь в моем тесте я могу повторно использовать класс Startup.

Еще лучше, я могу создать подкласс Startup, чтобы я мог смоделировать фрагменты кода, которые используют какой-то ввод-вывод, чтобы сделать мои интеграционные тесты более устойчивыми:

public class CreateAccountFunctionTests
{
    private readonly CreateAccountFunction m_creationAccountFunction;

    public CreateAccountFunctionTests()
    {
        var startup = new Startup();

        m_creationAccountFunction = HostHelper.Instanciate<CreateAccountFunction>(startup);
    }

    [Fact]
    public void TestSomething()
    {
        // Arrange.
        HttpRequest httpRequest = /* builds an instance of HttpRequest */

        // Act.
        var result = m_creationAccountFunction.Run(httpRequest);

        // Assert.
        // Asserts the Status Code.
    }
}

Обновление

Как предлагается в комментариях, я поместил класс на GitHub для удобства доступа. Вот полный класс:

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using Microsoft.Extensions.Hosting;

namespace NoSuchCompany.QualityTools.Service.Automation.Hosting
{
    #region Class

    /// <summary>
    /// Builds a <see cref="IHost"/> instance that can be used to inject parameters into a Function.
    /// </summary>
    /// <remarks>
    /// To use it for integration tests, first build a Startup class or one derived from it that contains
    /// mock instances of the services to inject.
    ///
    /// public class Startup
    /// {
    ///     public override void Configure(IFunctionsHostBuilder functionsHostBuilder)
    ///     {
    ///          ConfigureEmailService(functionsHostBuilder.Services);
    ///     }      
    ///
    /// 
    ///     protected virtual void ConfigureSomeService(IServiceCollection serviceCollection)
    ///     {
    ///        //  Inject a concrete service.
    ///        serviceCollection.AddTransient<ISomeService, SomeService>();
    ///     }
    /// }
    /// 
    /// public sealed class TestStartup : Startup
    /// {
    ///     protected override void ConfigureSomeService(IServiceCollection serviceCollection)
    ///     {
    ///        //  Inject a mock service.
    ///        serviceCollection.AddTransient<ISomeService, MockOfSomeService>();
    ///     }
    /// }
    ///
    /// Then, the helper can be called with like this:
    ///
    /// var startup = new TestStartup();
    /// 
    /// var myAzureFunctionToTest = HostHelper.Instantiate<AnAzureFunction>(startup);
    /// 
    /// </remarks>
    [ExcludeFromCodeCoverage]
    public static class HostHelper
    {
        #region Public Methods

        /// <summary>
        /// Builds an instance of the specified <typeparamref name="TFunctionType"/>
        /// with the services defined in the <paramref name="startup"/> instance.
        /// </summary>
        /// <typeparam name="TFunctionType"></typeparam>
        /// <param name="startup"></param>
        /// <returns></returns>
        /// <exception cref="ArgumentNullException">
        /// Thrown if:
        /// - The <paramref name="startup" /> instance is not specified.
        /// </exception>
        public static TFunctionType Instantiate<TFunctionType>(Startup startup)
        {
            if(startup is null)
                throw new ArgumentNullException($"The parameter {nameof(startup)} instance is not specified.");

            IHost host = new HostBuilder().ConfigureWebJobs(startup.Configure).Build();

            return Instantiate<TFunctionType>(host);
        }

        #endregion

        #region Private Methods

        /// <summary>
        /// Instantiates the specified <typeparamref name="TFunctionType"></typeparamref>.
        /// </summary>
        /// <typeparam name="TFunctionType"></typeparam>
        /// <param name="host"></param>
        /// <returns></returns>
        private static TFunctionType Instantiate<TFunctionType>(IHost host)
        {
            Type type = typeof(TFunctionType);

            ConstructorInfo constructorInfo = type.GetConstructors().FirstOrDefault();

            ParameterInfo[] parametersInfo = constructorInfo.GetParameters();

            object[] parameters = LookupServiceInstances(host, parametersInfo);

            return (TFunctionType) Activator.CreateInstance(type, parameters);
        }

        /// <summary>
        /// Gets all the parameters instances from the host's services.
        /// </summary>
        /// <param name="host"></param>
        /// <param name="parametersInfo"></param>
        /// <returns></returns>
        private static object[] LookupServiceInstances(IHost host, IReadOnlyList<ParameterInfo> parametersInfo)
        {
            return parametersInfo.Select(parameter => host.Services.GetService(parameter.ParameterType))
                                 .ToArray();
        }

        #endregion
    }

    #endregion
}
...