Как смоделировать новый HttpClientFactory в.NET Core 2.1, используя Moq

.NET Core 2.1 поставляется с этой новой фабрикой под названием HTTPClientFactory, но я не могу понять, как смоделировать его для модульного тестирования некоторых методов, которые включают вызовы службы REST.

Фабрика внедряется с использованием контейнера.NET Core IoC, и метод создает нового клиента из фабрики:

var client = _httpClientFactory.CreateClient();

А затем с помощью клиента получить данные из службы REST:

var result = await client.GetStringAsync(url);

7 ответов

HttpClientFactory происходит от IHttpClientFactory Интерфейс Так что это просто вопрос создания макета интерфейса

var mockFactory = new Mock<IHttpClientFactory>();

В зависимости от того, для чего вам нужен клиент, вам потребуется настроить макет для возврата HttpClient для теста.

Это, однако, требует фактического HttpClient,

var clientHandlerStub = new DelegatingHandlerStub();
var client = new HttpClient(clientHandlerStub);

mockFactory.Setup(_ => _.CreateClient(It.IsAny<string>())).Returns(client);

IHttpClientFactory factory = mockFactory.Object;

Завод может быть введен в зависимую систему при проведении теста.

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

Пример заглушки обработчика, используемой для подделки запросов

public class DelegatingHandlerStub : DelegatingHandler {
    private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _handlerFunc;
    public DelegatingHandlerStub() {
        _handlerFunc = (request, cancellationToken) => Task.FromResult(request.CreateResponse(HttpStatusCode.OK));
    }

    public DelegatingHandlerStub(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handlerFunc) {
        _handlerFunc = handlerFunc;
    }

    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) {
        return _handlerFunc(request, cancellationToken);
    }
}

Взято из ответа, который я дал здесь

Ссылка Mock HttpClient с использованием Moq

Предположим, у вас есть контроллер

[Route("api/[controller]")]
public class ValuesController : Controller {
    private readonly IHttpClientFactory _httpClientFactory;

    public ValuesController(IHttpClientFactory httpClientFactory) {
        _httpClientFactory = httpClientFactory;
    }

    [HttpGet]
    public async Task<IActionResult> Get() {
        var client = _httpClientFactory.CreateClient();
        var url = "http://example.com";
        var result = await client.GetStringAsync(url);
        return Ok(result);
    }
}

и хотел проверить Get() действие.

public async Task Should_Return_Ok() {
    //Arrange
    var expected = "Hello World";
    var mockFactory = new Mock<IHttpClientFactory>();
    var clientHandlerStub = new DelegatingHandlerStub((request, cancellationToken) => {
        var response = request.CreateResponse(HttpStatusCode.OK, expected);
        return Task.FromResult(response);
    });
    var client = new HttpClient(clientHandlerStub);

    mockFactory.Setup(_ => _.CreateClient(It.IsAny<string>())).Returns(client);

    IHttpClientFactory factory = mockFactory.Object;

    var controller = new ValuesController(factory);

    //Act
    var result = await controller.Get();

    //Assert
    result.Should().NotBeNull();

    var okResult = result as OkObjectResult;

    var actual = (string) okResult.Value;

    actual.Should().Be(expected);
}

В дополнение к предыдущему сообщению, в котором описывается, как настроить заглушку, вы можете просто использовать Moq для настройки DelegatingHandler:

var clientHandlerMock = new Mock<DelegatingHandler>();
clientHandlerMock.Protected()
    .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
    .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK))
    .Verifiable();
clientHandlerMock.As<IDisposable>().Setup(s => s.Dispose());

var httpClient = new HttpClient(clientHandlerMock.Object);

var clientFactoryMock = new Mock<IHttpClientFactory>(MockBehavior.Strict);
clientFactoryMock.Setup(cf => cf.CreateClient()).Returns(httpClient).Verifiable();

clientFactoryMock.Verify(cf => cf.CreateClient());
clientHandlerMock.Protected().Verify("SendAsync", Times.Exactly(1), ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>());

Я использовал пример из @Nkosi, но с .NET 5 Я получил следующее предупреждение с пакетом Microsoft.AspNet.WebApi.Core необходимо для.

Предупреждение NU1701 Пакет «Microsoft.AspNet.WebApi.Core 5.2.7» был восстановлен с помощью .NETFramework,Version = v4.6.1, .NETFramework,Version = v4.6.2, .NETFramework,Version = v4.7, .NETFramework,Version = v4.7.1, .NETFramework,Version = v4.7.2, .NETFramework,Version = v4.8'вместо целевой платформы проекта' net5.0 '. Этот пакет может быть не полностью совместим с вашим проектом.

Полный пример без использования HttpConfiguration:

      private LoginController GetLoginController()
{
    var expected = "Hello world";
    var mockFactory = new Mock<IHttpClientFactory>();

    var mockMessageHandler = new Mock<HttpMessageHandler>();
    mockMessageHandler.Protected()
        .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
        .ReturnsAsync(new HttpResponseMessage
        {
            StatusCode = HttpStatusCode.OK,
            Content = new StringContent(expected)
        });

    var httpClient = new HttpClient(mockMessageHandler.Object);

    mockFactory.Setup(_ => _.CreateClient(It.IsAny<string>())).Returns(httpClient);

    var logger = Mock.Of<ILogger<LoginController>>();

    var controller = new LoginController(logger, mockFactory.Object);

    return controller;
}

Источник:

HttpConfiguration из System.Web.Http в проекте .NET 5

Для тех, кто хочет добиться того же результата, используя макет IHttpClientFactoryс делегатом, чтобы избежать вызовов конечных точек во время тестирования и которые используют версию .NET Coreвыше чем 2.2(там, где кажется Microsoft.AspNet.WebApi.Coreпакет, содержащий HttpRequestMessageExtensions.CreateResponseрасширение больше недоступно, если не полагаться на пакет, предназначенный для .NET Core 2.2), то приведенная ниже адаптация ответа Нкоси выше сработала для меня в .NET 5.

Можно просто использовать экземпляр HttpRequestMessageнапрямую, если это все, что требуется.

      public class DelegatingHandlerStub : DelegatingHandler
{
    private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _handlerFunc;
    
    public HttpHandlerStubDelegate()
    {
        _handlerFunc = (request, cancellationToken) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
    }

    public HttpHandlerStubDelegate(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handlerFunc)
    {
        _handlerFunc = handlerFunc;
    }

    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        return _handlerFunc(request, cancellationToken);
    }
}

Что касается использования в тесте Setupметод, аналогично, я использовал экземпляр HttpResponseMessageнапрямую. В моем случае factoryMockзатем передается в пользовательский адаптер, который обертывает и, следовательно, настроен на использование нашего поддельного HttpClient.

      var expected = @"{ ""foo"": ""bar"" }";
var clientHandlerStub = new HttpHandlerStubDelegate((request, cancellationToken) => {
    var response = new HttpResponseMessage() { StatusCode = HttpStatusCode.OK, Content = new StringContent(expected) };
    return Task.FromResult(response);
});

var factoryMock = new Mock<IHttpClientFactory>();
factoryMock.Setup(m => m.CreateClient(It.IsAny<string>()))
    .Returns(() => new HttpClient(clientHandlerStub));

И, наконец, пример NUnitтестовое тело, использующее это, которое проходит.

      [Test]
public async Task Subject_Condition_Expectation()
{
    var expected = @"{ ""foo"": ""bar"" }";

    var result = await _myHttpClientWrapper.GetAsync("https://www.example.com/api/stuff");
    var actual = await result.Content.ReadAsStringAsync();

    Assert.AreEqual(expected, actual);
}

Этот код вызвал для меня это исключение System.InvalidOperationException: у запроса нет связанного объекта конфигурации, или предоставленная конфигурация была нулевой.

Так что включили это в метод испытания, и это работает.

var configuration = new HttpConfiguration();
var request = new HttpRequestMessage();
request.SetConfiguration(configuration);

Как бы то ни было, это моя реализация с использованием .NET 7 и функций Azure v4. Это работоспособный макет HttpClientFactory с поддержкой нескольких запросов.

Настройка макета модульного теста


MockHttpClientFactory

      public class MockHttpClientFactory
{
    public static IHttpClientFactory Create(string name, MockHttpResponse response)
    {
        return Create(name, new List<MockHttpResponse> { response });
    }


    public static IHttpClientFactory Create(string name, List<MockHttpResponse> responses)
    {
                    
        Mock<HttpMessageHandler> messageHandler = SendAsyncHandler(responses);

        var mockHttpClientFactory = new Mock<IHttpClientFactory>();

        mockHttpClientFactory
            .Setup(x => x.CreateClient(name))
            .Returns(new HttpClient(messageHandler.Object)
            {
                BaseAddress = new Uri("https://mockdomain.mock")
            });

        return mockHttpClientFactory.Object;
    }


    private static Mock<HttpMessageHandler> SendAsyncHandler(List<MockHttpResponse> responses)
    {
        var messageHandler = new Mock<HttpMessageHandler>(MockBehavior.Strict);

        foreach(var response in responses)
        {
            messageHandler
                .Protected()
                .Setup<Task<HttpResponseMessage>>("SendAsync",
                    ItExpr.Is<HttpRequestMessage>(r => r.RequestUri!.PathAndQuery == response.UrlPart),
                    ItExpr.IsAny<CancellationToken>())
                .ReturnsAsync(new HttpResponseMessage
                {
                    StatusCode = response.StatusCode,
                    Content = (response.Response?.GetType() == typeof(string)
                        ? new StringContent(response.Response?.ToString() ?? "")
                        : new StringContent(JsonSerializer.Serialize(response.Response)))
                })
                .Verifiable();
        }               

        return messageHandler;
    }
}

MockHttpResponse

      public class MockHttpResponse
{
    public MockHttpResponse()
    {           
    }

    public MockHttpResponse(string urlPart, object response, HttpStatusCode statusCode)
    {
        this.UrlPart = urlPart;
        this.Response = response;
        this.StatusCode = statusCode;
    }


    public string UrlPart { get; set; } = String.Empty;

    public object Response { get; set; } = default!;

    public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
}

MockHttpRequestData

      public class MockHttpRequestData
{ 
    public static HttpRequestData Create()
    {
        return Create<string>("");
    }   
    

    public static HttpRequestData Create<T>(T requestData) where T : class
    {
        var serviceCollection = new ServiceCollection();
        serviceCollection.AddFunctionsWorkerDefaults();

        var serializedData = JsonSerializer.Serialize(requestData);
        var bodyDataStream = new MemoryStream(Encoding.UTF8.GetBytes(serializedData));

        var context = new Mock<FunctionContext>();
        context.SetupProperty(context => context.InstanceServices, serviceCollection.BuildServiceProvider());

        var request = new Mock<HttpRequestData>(context.Object);
        request.Setup(r => r.Body).Returns(bodyDataStream);
        request.Setup(r => r.CreateResponse()).Returns(new MockHttpResponseData(context.Object));

        return request.Object;
    }
}

MockHttpResponseData

      public class MockHttpResponseData : HttpResponseData
{
    public MockHttpResponseData(FunctionContext functionContext) : base(functionContext)
    {           
    }
    

    public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;

    public override HttpHeadersCollection Headers { get; set; } = new HttpHeadersCollection();

    public override Stream Body { get; set; } = new MemoryStream();

    public override HttpCookies Cookies { get; }
}

Применение


Метод функции Azure

Эта функция Azure настроена с помощью DI и использует объект HttpClient. Подробности выходят за рамки этого поста. Вы можете Google для получения дополнительной информации.

      public class Function1
{
    private readonly HttpClient httpClient;


    public Function1(IHttpClientFactory httpClientFactory)
    {
        this.httpClient = httpClientFactory.CreateClient("WhateverYouNamedIt");
    }



    [Function("Function1")]
    public async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Function, "get", "post")] HttpRequestData req)
    {
        var httpResponse = await this.httpClient.GetAsync("/some-path");
        var httpResponseContent = await httpResponse.Content.ReadAsStringAsync();

        // do something with the httpResponse or Content

        var response = req.CreateResponse(HttpStatusCode.OK);
        await response.WriteStringAsync(httpResponseContent);
        
        return response;
    }               
}

Простой вариант использования

      public class UnitTest1
{
    [Fact]
    public void Test1()
    {
        var httpClientFactory = MockHttpClientFactory.Create("WhateverYouNamedIt", new MockHttpResponse());

        var exception = Record.Exception(() => new Function1(httpClientFactory));

        Assert.Null(exception);
    }
}

Более реалистичный вариант использования

          [Fact]
    public async Task Test2()
    {
        var httpResponses = new List<MockHttpResponse>
        {
            new MockHttpResponse
            {
                UrlPart = "/some-path",
                Response = new { Name = "data" }
            }
        };

        var httpClientFactory = MockHttpClientFactory.Create("WhateverYouNamedIt", httpResponses);
        var httpRequestData = MockHttpRequestData.Create();

        var function1 = new Function1(httpClientFactory);
        var function1Response = await function1.Run(httpRequestData);
        function1Response.Body.Position = 0;

        using var streamReader = new StreamReader(function1Response.Body);
        var function1ResponseBody = await streamReader.ReadToEndAsync();
                
        Assert.Equal("{\"Name\":\"data\"}", function1ResponseBody);
    }

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

Другие вопросы по тегам