如何使用Moq模拟.NET Core 2.1中的新HttpClientFactory

61

.NET Core 2.1引入了一个名为HttpClientFactory的新工厂,但我不知道如何模拟它以对包含REST服务调用的某些方法进行单元测试。

该工厂使用.NET Core IoC容器进行注入,方法所做的就是从工厂创建一个新客户端:

var client = _httpClientFactory.CreateClient();

然后使用客户端从REST服务获取数据:

var result = await client.GetStringAsync(url);
7个回答

97

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 using 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 configuration = new HttpConfiguration();
    var clientHandlerStub = new DelegatingHandlerStub((request, cancellationToken) => {
        request.SetConfiguration(configuration);
        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);
}

2
当我尝试使用Moq做类似的事情时,我收到了来自Moq的错误,因为它不支持对扩展方法进行设置,这就是我首先提出问题的原因,无论如何,明天我会尝试你的版本。 - Mauricio Atanache
5
请注意,我设置了实际的接口成员而不是扩展方法。最终,扩展将调用接口方法。 - Nkosi
15
如果有人好奇,我在寻找适用于request.CreateResponse()的合适引用时遇到了问题 - 它可以在HttpRequestMessageExtensions中找到。 我通过向单元测试项目添加Microsoft.AspNet.WebApi.Core包来解决这个问题。 - Carter Musick
4
哇,真是太痛苦了……我希望CreateClient()方法返回一个接口而不是具体的HttpClient类型。 - Tom Faltesek
1
@ditzel 给出了很好的建议,但在这种情况下不应该有第二次调用。此答案针对单独的单元测试中预期的一次调用。如果被测试的目标需要创建多个客户端,则在设置测试时将使用函数委托。感谢您的有效观察。 - Nkosi
显示剩余10条评论

30

除了之前的文章介绍如何设置桩对象,你也可以使用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>());

6
因为cf.CreateClient()是一个扩展方法,Moq在模拟表达式中不允许使用它,所以我遇到了一个错误。要解决这个问题,可以使用clientFactoryMock.Setup(cf => cf.CreateClient(It.IsAny<string>())) - Isaac Lyman
@IsaacLyman 尝试模拟 .CreateClient(It.IsAny<string>()) - sourcx

14

我使用了@Nkosi提供的示例,但在使用.NET 5时,与HttpConfiguration所需的包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;
}

来源:

在 .NET 5 项目中使用 System.Web.Http 的 HttpConfiguration


8

针对那些希望通过使用IHttpClientFactory的模拟与HttpClient委托实现避免在测试期间调用端点,并且正在使用高于2.2.NET Core版本(在该版本中,似乎包含HttpRequestMessageExtensions.CreateResponse扩展的Microsoft.AspNet.WebApi.Core包已经不再可用,除非依赖于面向.NET Core 2.2的包),则可以使用下面适应Nkosi上述答案的方法,在.NET 5中为我工作。

如果只需要一个HttpRequestMessage实例,则可以直接使用它。

public class HttpHandlerStubDelegate : 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 并因此设置为使用我们的假 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);
}

2

这段代码在我的电脑上抛出了以下异常:

System.InvalidOperationException: 请求没有关联的配置对象或提供的配置为 null。

我在测试方法中添加了以下内容,问题得到了解决。

最初的回答:

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

0
一个不同的方法可能是创建一个额外的类,该类将在内部调用服务。这个类可以很容易地被模拟。 虽然它不是一个直接的答案,但它似乎要简单得多,更易于测试。

0

就我个人而言,这是我使用 .NET 7 和 Azure Functions 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对象。有关详细信息不在本篇文章的范围内。您可以通过谷歌搜索获取更多信息。

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);
    }

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接