在单元测试中模拟HttpClient

257

我在尝试对我的代码进行单元测试时遇到了一些问题。问题是这样的,我有一个接口IHttpHandler:


public interface IHttpHandler
{
    HttpClient client { get; }
}

而使用它的类是 HttpHandler

public class HttpHandler : IHttpHandler
{
    public HttpClient client
    {
        get
        {
            return new HttpClient();
        }
    }
}

然后是 Connection 类,它使用 simpleIOC 来注入客户端实现:

public class Connection
{
    private IHttpHandler _httpClient;

    public Connection(IHttpHandler httpClient)
    {
        _httpClient = httpClient;
    }
}

然后我有一个单元测试项目,其中包含这个类:

private IHttpHandler _httpClient;

[TestMethod]
public void TestMockConnection()
{
    var client = new Connection(_httpClient);
     
    client.doSomething();  

    // Here I want to somehow create a mock instance of the http client
    // Instead of the real one. How Should I approach this?     

}

显然,Connection类中将有从后端检索数据(JSON)的方法。然而,我想为这个类编写单元测试,显然我不想针对真正的后端编写测试,而是使用模拟后端。我尝试过谷歌搜索一个好的解决方案,但没有取得很大的成功。我以前用过Moq进行模拟,但从未在像HttpClient这样的东西上使用过。我应该如何解决这个问题?


1
在你的接口中暴露一个 HttpClient 是有问题的。这会强制客户端使用 HttpClient 具体类。相反,你应该暴露一个 HttpClient抽象 - Mike Eason
1
你能更深入地解释一下吗?我应该如何构建连接类的构造函数,因为我不希望在使用Connection类的其他类中有任何HttpClient的依赖关系。例如,我不想在Connection的构造函数中传递具体的HttpClient,因为这会使得使用Connection的每个其他类都依赖于HttpClient。 - tjugg
1
你感兴趣的是什么?显然,MockHttp 需要进行一些 SEO 改进。 - Richard Szalay
@Mike - 正如我在答案中提到的,没有必要对 HttpClient 进行抽象化。它本身就可以进行完美的测试。我有很多项目都使用这种方法进行后端无关的测试套件。 - Richard Szalay
24个回答

398

HttpClient 的可扩展性在于传递到构造函数的 HttpMessageHandler。它的目的是允许平台特定的实现,但您也可以模拟它。不需要为 HttpClient 创建装饰器包装器。

如果您更喜欢使用 DSL 而非 Moq,我在 GitHub/Nuget 上有一个库可以使事情变得更加容易: https://github.com/richardszalay/mockhttp

Nuget 包 RichardSzalay.MockHttp 可在此处获得

var mockHttp = new MockHttpMessageHandler();

// Setup a respond for the user api (including a wildcard in the URL)
mockHttp.When("http://localhost/api/user/*")
        .Respond("application/json", "{'name' : 'Test McGee'}"); // Respond with JSON

// Inject the handler or client into your application code
var client = new HttpClient(mockHttp);

var response = await client.GetAsync("http://localhost/api/user/1234");
// or without async: var response = client.GetAsync("http://localhost/api/user/1234").Result;

var json = await response.Content.ReadAsStringAsync();

// No network connection required
Console.Write(json); // {'name' : 'Test McGee'}

10
对于不想处理客户端注入但仍然希望轻松进行测试的人来说,这是很简单的。只需将 var client = new HttpClient() 替换为 var client = ClientFactory() 并设置一个字段 internal static Func<HttpClient> ClientFactory = () => new HttpClient();,在测试级别上,您可以重写此字段。 - Chris Marisic
10
你建议使用一种服务定位来替代注入。服务定位是一个众所周知的反模式,因此在我看来,注入是更可取的。 - MarioDS
4
@MarioDS,无论如何,你都不应该注入一个 HttpClient 实例。如果你非常坚定地想使用构造函数注入,则应该注入一个 HttpClientFactory,如 Func<HttpClient> 所示。由于我认为 HttpClient 纯粹是一个实现细节而不是依赖项,所以我会像上面演示的那样使用静态方法。我完全接受测试操作内部状态。如果我关心纯洁主义,我会搭建完整的服务器并测试实际代码路径。使用任何类型的模拟意味着你接受行为的近似而不是实际行为。 - Chris Marisic
4
作为更新,现在明确建议您使用单个 HttpClient 实例。 - Richard Szalay
4
这里是Moq-land中的一种策略:https://gingter.org/2018/07/26/how-to-mock-httpclient-in-your-net-c-unit-tests/。 - ruffin
显示剩余15条评论

116

我同意其他答案中的某些观点,即最好的方法是在HttpClient内部模拟HttpMessageHandler而不是包装它。这个答案的独特之处在于仍然注入了HttpClient,使其能够成为单例或使用依赖注入进行管理。

HttpClient旨在在应用程序的整个生命周期中实例化一次并重复使用。

(来源).

模拟HttpMessageHandler可能有些棘手,因为SendAsync是受保护的。这里是一个完整的示例,使用xunit和Moq。

using System;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using Moq.Protected;
using Xunit;
// Use nuget to install xunit and Moq

namespace MockHttpClient {
    class Program {
        static void Main(string[] args) {
            var analyzer = new SiteAnalyzer(Client);
            var size = analyzer.GetContentSize("http://microsoft.com").Result;
            Console.WriteLine($"Size: {size}");
        }

        private static readonly HttpClient Client = new HttpClient(); // Singleton
    }

    public class SiteAnalyzer {
        public SiteAnalyzer(HttpClient httpClient) {
            _httpClient = httpClient;
        }

        public async Task<int> GetContentSize(string uri)
        {
            var response = await _httpClient.GetAsync( uri );
            var content = await response.Content.ReadAsStringAsync();
            return content.Length;
        }

        private readonly HttpClient _httpClient;
    }

    public class SiteAnalyzerTests {
        [Fact]
        public async void GetContentSizeReturnsCorrectLength() {
            // Arrange
            const string testContent = "test content";
            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(testContent)
                });
            var underTest = new SiteAnalyzer(new HttpClient(mockMessageHandler.Object));

            // Act
            var result = await underTest.GetContentSize("http://anyurl");

            // Assert
            Assert.Equal(testContent.Length, result);
        }
    }
}

4
我很喜欢这个例子。mockMessageHandler.Protected() 是关键所在。谢谢你提供这个例子,它使得我们可以在完全不修改源代码的情况下编写测试。 - tyrion
2
FYI,Moq 4.8支持对受保护成员进行强类型模拟 - https://github.com/Moq/moq4/wiki/Quickstart - Richard Szalay
3
看起来很不错。此外,Moq支持ReturnsAsync,因此代码将如下所示:.ReturnsAsync(new HttpResponseMessage {StatusCode = HttpStatusCode.OK, Content = new StringContent(testContent)}) - kord
3
有没有办法验证 "SandAsync" 被调用时带有一些参数? 我尝试使用...Protected().Verify(...),但似乎它不适用于异步方法。 - Rroman
2
@Rroman 使用回调函数来捕获输入参数。异步方法的技巧在于你必须从中返回一些东西:.Setup(....).Callback(...).Returns(Task.FromResult(...))。 - Ian Jacobs
显示剩余2条评论

56

你的接口公开了具体的HttpClient类,因此使用此接口的任何类都与它绑定,这意味着它无法被模拟。

HttpClient没有继承任何接口,因此您需要编写自己的接口。我建议采用装饰者模式(Decorator-like)

public interface IHttpHandler
{
    HttpResponseMessage Get(string url);
    HttpResponseMessage Post(string url, HttpContent content);
    Task<HttpResponseMessage> GetAsync(string url);
    Task<HttpResponseMessage> PostAsync(string url, HttpContent content);
}

而你的类将会像这样:

public class HttpClientHandler : IHttpHandler
{
    private HttpClient _client = new HttpClient();

    public HttpResponseMessage Get(string url)
    {
        return GetAsync(url).Result;
    }

    public HttpResponseMessage Post(string url, HttpContent content)
    {
        return PostAsync(url, content).Result;
    }

    public async Task<HttpResponseMessage> GetAsync(string url)
    {
        return await _client.GetAsync(url);
    }

    public async Task<HttpResponseMessage> PostAsync(string url, HttpContent content)
    {
        return await _client.PostAsync(url, content);
    }
}

在这一切中的重点是,HttpClientHandler创建了自己的HttpClient,因此您可以创建多个实现IHttpHandler的类以不同的方式处理。

这种方法的主要问题在于,您实际上是编写一个只调用另一个类中方法的类,但是您可以创建一个继承自HttpClient的类(请参见Nkosi的示例,这比我的方法更好)。如果HttpClient有一个接口可供模拟,生活将变得容易得多,不幸的是它没有。

但是这个例子并不是万能的。 IHttpHandler仍然依赖于属于System.Net.Http命名空间的HttpResponseMessage,因此如果您需要使用除HttpClient 外的其他实现,则必须执行某种映射以将其响应转换为HttpResponseMessage对象。当然,这只是在您需要使用多个IHttpHandler 实现时才会出现问题,但看起来您并不需要,所以这不是世界末日,但值得考虑。

无论如何,您可以简单地模拟IHttpHandler,而不必担心具体的HttpClient类,因为它已经被抽象化了。

我建议测试非异步方法,因为这些方法仍然调用异步方法,但不必担心单元测试异步方法的麻烦,请参见此处


这确实回答了我的问题。Nkosis的答案也是正确的,所以我不确定应该接受哪一个作为答案,但我会选择这个。谢谢你们两个的努力。 - tjugg
@tjugg 很高兴能帮忙。如果您觉得这些答案有用,请随意点赞。 - Nkosi
3
值得注意的是,这个答案和Nkosi的主要区别在于它是一个更薄的抽象层。对于“谦逊对象”模式来说,薄可能更好。 - Ben Aaronson
1
请添加一条注释,说明这应该被注册为Singleton。创建大量的HttpClient并不好-https://www.aspnetmonsters.com/2016/08/2016-08-27-httpclientwrong/ - Peter Morris

37

这里有一个简单的解决方案,对我来说效果很好。

使用moq模拟库。

// ARRANGE
var handlerMock = new Mock<HttpMessageHandler>(MockBehavior.Strict);
handlerMock
   .Protected()
   // Setup the PROTECTED method to mock
   .Setup<Task<HttpResponseMessage>>(
      "SendAsync",
      ItExpr.IsAny<HttpRequestMessage>(),
      ItExpr.IsAny<CancellationToken>()
   )
   // prepare the expected response of the mocked http call
   .ReturnsAsync(new HttpResponseMessage()
   {
      StatusCode = HttpStatusCode.OK,
      Content = new StringContent("[{'id':1,'value':'1'}]"),
   })
   .Verifiable();

// use real http client with mocked handler here
var httpClient = new HttpClient(handlerMock.Object)
{
   BaseAddress = new Uri("http://test.com/"),
};

var subjectUnderTest = new MyTestClass(httpClient);

// ACT
var result = await subjectUnderTest
   .GetSomethingRemoteAsync('api/test/whatever');

// ASSERT
result.Should().NotBeNull(); // this is fluent assertions here...
result.Id.Should().Be(1);

// also check the 'http' call was like we expected it
var expectedUri = new Uri("http://test.com/api/test/whatever");

handlerMock.Protected().Verify(
   "SendAsync",
   Times.Exactly(1), // we expected a single external request
   ItExpr.Is<HttpRequestMessage>(req =>
      req.Method == HttpMethod.Get  // we expected a GET request
      && req.RequestUri == expectedUri // to this uri
   ),
   ItExpr.IsAny<CancellationToken>()
);

来源: https://gingter.org/2018/07/26/how-to-mock-httpclient-in-your-net-c-unit-tests/


1
我也成功地使用了这个。我更喜欢这种方法,而不是在另一个NuGet依赖项中进行碎片整理,你还可以更深入地了解底层发生的事情。好处是大多数方法最终都会使用SendAsync,因此无需额外设置。 - Steve Pettifer

33

这是一个常见的问题,我曾经非常倾向于想要对HttpClient进行模拟,但我认为我最终意识到你不应该对HttpClient进行模拟。虽然这样做似乎很合理,但我认为我们已经被开源库中看到的东西洗脑了。

我们经常在代码中Mock“客户端”以便进行隔离测试,因此我们自动尝试将相同的原则应用于HttpClient上。实际上HttpClient会做很多事情;您可以将其视为HttpMessageHandler的管理器,因此您不想对其进行模拟,这也是为什么它 仍然 没有接口的原因。您真正感兴趣的部分是 HttpMessageHandler,因为它返回响应,您可以对其进行模拟。

另外值得一提的是,您应该开始像对待重要组件一样对待HttpClient。例如:尽量减少新实例化HttpClient的数量。重复使用它们,它们被设计为可重复使用,如果您这样做,将使用更少的资源。如果您开始像对待重要组件一样对待它,那么想要对它进行模拟将会感觉非常错误,并且现在消息处理程序将开始成为您注入的内容,而不是客户端。

换句话说,围绕处理程序而非客户端设计您的依赖关系。甚至更好的是,抽象出使用HttpClient的“服务”,允许您注入处理程序,并将其用作可注入的依赖项。事实上,HttpClientFactor(您应该使用它)是设计为通过扩展注入消息处理程序的。然后在测试中,您可以伪造处理程序以控制响应以设置测试。

包装HttpClient是一种极其浪费时间的行为。

更新: 请查看Joshua Dooms的示例。这正是我推荐的内容。


1
所以,虽然我理解你的观点,但我想知道,这不是分离的目的吗?接口定义了某物应该做什么而不是它实际上做了什么,并且隐藏了所有内部细节(简单或复杂)。考虑到这一点,我认为HttpClient应该有一个接口,这样我就不必构建类似它的东西(接口和/或包装器类),也不必与模拟服务器交互(更多的内存和其他资源)。这只是我的想法... - Joshua G
@JoshuaG 不是真的。HttpClient实际上是一个相当低级别的协议实现。这就像是在嘲笑你的操作系统(夸张地说)。你试图分离的责任不会发生在那个层面,这就是为什么消息处理程序存在并且首先可以注入的组件。这是存在于开发者空间中的概念组件。作为开发人员,您正在开发消息处理(这是您想要模拟的部分),而不是网络协议。如果您想模拟网络协议,那么...我不知道为什么有人会这样做。 - Sinaesthetic
换句话说,如果你需要模拟握手、确认和 DNS 解析等操作,那么我可以理解为什么你需要模拟 HttpClient,但这是非常非常不常见的做法。如果你需要基于 HTTP 消息(代码、头部、负载、模拟超时等)来模拟行为,那就是消息处理程序,而不是客户端。 - Sinaesthetic

26

在其他答案的基础上,我建议使用以下代码,它不依赖于任何外部库:

[TestClass]
public class MyTestClass
{
    [TestMethod]
    public async Task MyTestMethod()
    {
        var httpClient = new HttpClient(new MockHttpMessageHandler());

        var content = await httpClient.GetStringAsync("http://some.fake.url");

        Assert.AreEqual("Content as string", content);
    }
}

public class MockHttpMessageHandler : HttpMessageHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var responseMessage = new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent("Content as string")
        };

        return await Task.FromResult(responseMessage);
    }
}

5
您正在有效地测试您的模拟对象。模拟对象的真正威力在于您可以在每个测试中设置期望并更改其行为。但是,必须实现某些HttpMessageHandler,这使得这一点几乎不可能-您必须这样做,因为这些方法是“protected internal”。 - MarioDS
4
@MarioDS 我认为重点在于您可以模拟HTTP响应,以便测试其余代码。如果您注入一个获取HttpClient的工厂,那么在测试中您就可以提供这个HttpClient。 - chris31389

24
有几种不同的方法可以模拟一个 HttpClient。以下是我在使用 xUnit 进行一些 POC 后决定采用单一解决方案(Moq.Contrib.HttpClient)的一些方法。请注意,每个框架的能力远不止下面所示的内容; 我为了清晰起见,保持每个示例简洁。

Moq(独立使用)

如果您熟悉使用 Moq 框架,那么这相对比较简单。 "技巧" 是在 HttpClient 内部模拟 HttpMessageHandler,而不是模拟 HttpClient 本身。 注意:最好在模拟中使用 MockBehavior.Strict,以便您被警告任何您没有明确模拟并且期望的调用。

RichardSzalay.MockHttp

RichardSzalay.MockHttp是另一个流行的解决方案。我过去用过这个,但发现它比Moq.Contrib.HttpClient略显繁琐。这里有两种不同的模式可以使用。 Richard在这里描述了何时使用其中一种。

Moq.Contrib.HttpClient

像单独使用 Moq 的解决方案一样,如果您熟悉使用 Moq 框架,那么这相对比较简单。我发现这个解决方案更直接、代码更少。这是我选择使用的解决方案。请注意,此解决方案需要与 Moq 本身分开安装 - Moq.Contrib.HttpClient

WireMock.Net

作为一个新来者,WireMock.net 正变得越来越受欢迎。如果您正在编写集成测试,并且实际进行端点调用而不是模拟,则这将是一个合理的替代 Microsoft.AspNetCore.TestHost 的解决方案。我最初认为这将是我的选择,但出于两个原因而决定不采用:

  1. 实际上,它会打开端口以便进行测试。由于我过去曾因不正确使用HttpClient而必须修复端口用尽问题,所以我决定放弃这个解决方案,因为我不确定它在许多并行运行的单元测试中是否能很好地扩展到一个大型代码库中。
  2. 使用的 URL 必须是可解析的(实际合法的URL)。如果你希望简化不关心“真实” URL 的情况(只要你期望的 URL 被实际调用即可),那么这可能不适合您。

示例

给定以下简单/人为构造的代码,下面是如何编写每个测试的方法。

public class ClassUnderTest
{
    private readonly HttpClient _httpClient;
    private const string Url = "https://myurl";

    public ClassUnderTest(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<Person> GetPersonAsync(int id)
    {
        var response = await _httpClient.GetAsync($"{Url}?id={id}");
        return await response.Content.ReadFromJsonAsync<Person>();
    }
}

public class Person
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int Age { get; set; }
}

Moq (by itself)

[Fact]
public async Task JustMoq()
{
    //arrange
    const int personId = 1;
    var mockHandler = new Mock<HttpMessageHandler>(MockBehavior.Strict);
    var dto = new Person { Id = personId, Name = "Dave", Age = 42 };
    var mockResponse = new HttpResponseMessage
    {
        StatusCode = HttpStatusCode.OK,
        Content = JsonContent.Create<Person>(dto)
    };

    mockHandler
        .Protected()
        .Setup<Task<HttpResponseMessage>>(
            "SendAsync",
            ItExpr.Is<HttpRequestMessage>(m => m.Method == HttpMethod.Get),
            ItExpr.IsAny<CancellationToken>())
        .ReturnsAsync(mockResponse);

    // Inject the handler or client into your application code
    var httpClient = new HttpClient(mockHandler.Object);
    var sut = new ClassUnderTest(httpClient);

    //act
    var actual = await sut.GetPersonAsync(personId);

    //assert
    Assert.NotNull(actual);
    mockHandler.Protected().Verify(
        "SendAsync",
        Times.Exactly(1),
        ItExpr.Is<HttpRequestMessage>(m => m.Method == HttpMethod.Get),
        ItExpr.IsAny<CancellationToken>());
}

RichardSzalay.MockHttp(使用后端定义模式)

[Fact]
public async Task RichardSzalayMockHttpUsingBackendDefinition()
{
    //arrange
    const int personId = 1;
    using var mockHandler = new MockHttpMessageHandler();
    var dto = new Person { Id = personId, Name = "Dave", Age = 42 };
    var mockResponse = new HttpResponseMessage
    {
        StatusCode = HttpStatusCode.OK,
        Content = JsonContent.Create<Person>(dto)
    };

    var mockedRequest = mockHandler.When(HttpMethod.Get, "https://myurl?id=1")
        .Respond(mockResponse.StatusCode, mockResponse.Content);

    // Inject the handler or client into your application code
    var httpClient = mockHandler.ToHttpClient();
    var sut = new ClassUnderTest(httpClient);

    //act
    var actual = await sut.GetPersonAsync(personId);

    //assert
    Assert.NotNull(actual);
    Assert.Equivalent(dto, actual);
    Assert.Equal(1, mockHandler.GetMatchCount(mockedRequest));
    mockHandler.VerifyNoOutstandingRequest();
}

RichardSzalay.MockHttp(使用RequestExpectation模式)


[Fact]
public async Task RichardSzalayMockHttpUsingRequestExpectation()
{
    //arrange
    const int personId = 1;
    using var mockHandler = new MockHttpMessageHandler();
    var dto = new Person { Id = personId, Name = "Dave", Age = 42 };
    var mockResponse = new HttpResponseMessage
    {
        StatusCode = HttpStatusCode.OK,
        Content = JsonContent.Create<Person>(dto)
    };

    var mockedRequest = mockHandler.Expect(HttpMethod.Get, "https://myurl")
        .WithExactQueryString($"id={personId}")
        .Respond(mockResponse.StatusCode, mockResponse.Content);

    // Inject the handler or client into your application code
    var httpClient = mockHandler.ToHttpClient();
    var sut = new ClassUnderTest(httpClient);

    //act
    var actual = await sut.GetPersonAsync(personId);

    //assert
    Assert.NotNull(actual);
    Assert.Equivalent(dto, actual);
    Assert.Equal(1, mockHandler.GetMatchCount(mockedRequest));
    mockHandler.VerifyNoOutstandingExpectation();
}

Moq.Contrib.HttpClient

[Fact]
public async Task UsingMoqContribHttpClient()
{
    //arrange
    const int personId = 1;
    var mockHandler = new Mock<HttpMessageHandler>(MockBehavior.Strict);
    var dto = new Person { Id = personId, Name = "Dave", Age = 42 };
    var mockUrl = $"https://myurl?id={personId}";
    var mockResponse = mockHandler.SetupRequest(HttpMethod.Get, mockUrl)
        .ReturnsJsonResponse<Person>(HttpStatusCode.OK, dto);

    // Inject the handler or client into your application code
    var httpClient = mockHandler.CreateClient();
    var sut = new ClassUnderTest(httpClient);

    //act
    var actual = await sut.GetPersonAsync(personId);

    //assert
    Assert.NotNull(actual);
    Assert.Equivalent(dto, actual);
    mockHandler.VerifyRequest(HttpMethod.Get, mockUrl, Times.Once());
}

WireMock.NET

public class TestClass : IDisposable
{
    private WireMockServer _server;

    public TestClass()
    {
        _server = WireMockServer.Start();
    }

    public void Dispose()
    {
        _server.Stop();
    }

    [Fact]
    public async Task UsingWireMock()
    {
        //arrange
        const int personId = 1;
        var dto = new Person { Id = personId, Name = "Dave", Age = 42 };
        var mockUrl = $"https://myurl?id={personId}";

        _server.Given(
            Request.Create()
                .WithPath("/"))
            .RespondWith(
                Response.Create()
                    .WithStatusCode(200)
                    .WithHeader("Content-Type", "application/json")
                    .WithBodyAsJson(dto));

        // Inject the handler or client into your application code
        var httpClient = _server.CreateClient();
        var sut = new ClassUnderTest(httpClient);

        //act
        var actual = await sut.GetPersonAsync(personId);

        //assert
        Assert.NotNull(actual);
        Assert.Equivalent(dto, actual);
    }
}

22

正如评论中提到的那样,您需要抽象化HttpClient以避免与其耦合。我过去也做过类似的事情。我会尝试根据您要做的事情来适应我的经验。

首先查看HttpClient类并决定所需功能。

以下是可能的方案:

public interface IHttpClient {
    System.Threading.Tasks.Task<T> DeleteAsync<T>(string uri) where T : class;
    System.Threading.Tasks.Task<T> DeleteAsync<T>(Uri uri) where T : class;
    System.Threading.Tasks.Task<T> GetAsync<T>(string uri) where T : class;
    System.Threading.Tasks.Task<T> GetAsync<T>(Uri uri) where T : class;
    System.Threading.Tasks.Task<T> PostAsync<T>(string uri, object package);
    System.Threading.Tasks.Task<T> PostAsync<T>(Uri uri, object package);
    System.Threading.Tasks.Task<T> PutAsync<T>(string uri, object package);
    System.Threading.Tasks.Task<T> PutAsync<T>(Uri uri, object package);
}

如前所述,这是为特定目的而设计的。我将大多数依赖于任何与 HttpClient 相关的内容都完全抽象化,并专注于我想要返回的内容。您应该评估如何抽象化 HttpClient,以仅提供所需的功能。

现在,这将允许您仅模拟需要测试的内容。

我甚至建议完全放弃 IHttpHandler 并使用 HttpClient 抽象化 IHttpClient。但我只是提供建议,您可以使用抽象化客户端的成员替换处理程序接口的主体。

实现 IHttpClient 可以用于包装/适配真实/具体的 HttpClient 或任何其他对象,可用于发出 HTTP 请求,因为您真正想要的是提供该功能的服务,而不是特定的 HttpClient。使用抽象化是一种清晰(我认为)和 SOLID 的方法,如果您需要切换基础客户端以适应框架更改,则可以使代码更易于维护。

以下是如何完成实现的片段。

/// <summary>
/// HTTP Client adaptor wraps a <see cref="System.Net.Http.HttpClient"/> 
/// that contains a reference to <see cref="ConfigurableMessageHandler"/>
/// </summary>
public sealed class HttpClientAdaptor : IHttpClient {
    HttpClient httpClient;

    public HttpClientAdaptor(IHttpClientFactory httpClientFactory) {
        httpClient = httpClientFactory.CreateHttpClient(**Custom configurations**);
    }

    //...other code

     /// <summary>
    ///  Send a GET request to the specified Uri as an asynchronous operation.
    /// </summary>
    /// <typeparam name="T">Response type</typeparam>
    /// <param name="uri">The Uri the request is sent to</param>
    /// <returns></returns>
    public async System.Threading.Tasks.Task<T> GetAsync<T>(Uri uri) where T : class {
        var result = default(T);
        //Try to get content as T
        try {
            //send request and get the response
            var response = await httpClient.GetAsync(uri).ConfigureAwait(false);
            //if there is content in response to deserialize
            if (response.Content.Headers.ContentLength.GetValueOrDefault() > 0) {
                //get the content
                string responseBodyAsText = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
                //desrialize it
                result = deserializeJsonToObject<T>(responseBodyAsText);
            }
        } catch (Exception ex) {
            Log.Error(ex);
        }
        return result;
    }

    //...other code
}

如上例所示,使用HttpClient通常需要大量的工作,但通过抽象化,这些工作都被隐藏了起来。
您可以将连接类与抽象客户端进行注入。
public class Connection
{
    private IHttpClient _httpClient;

    public Connection(IHttpClient httpClient)
    {
        _httpClient = httpClient;
    }
}

您的测试可以模拟系统下测试所需要的内容。
private IHttpClient _httpClient;

[TestMethod]
public void TestMockConnection()
{
    SomeModelObject model = new SomeModelObject();
    var httpClientMock = new Mock<IHttpClient>();
    httpClientMock.Setup(c => c.GetAsync<SomeModelObject>(It.IsAny<string>()))
        .Returns(() => Task.FromResult(model));

    _httpClient = httpClientMock.Object;

    var client = new Connection(_httpClient);

    // Assuming doSomething uses the client to make
    // a request for a model of type SomeModelObject
    client.doSomething();  
}

这就是答案。它是在HttpClient之上的一个抽象,并提供了一个适配器来使用HttpClientFactory创建您特定的实例。这样做可以使得超出HTTP请求的逻辑测试变得微不足道,这也是我们的目标。 - pim

19

我认为问题在于你把它颠倒了一点。

public class AuroraClient : IAuroraClient
{
    private readonly HttpClient _client;

    public AuroraClient() : this(new HttpClientHandler())
    {
    }

    public AuroraClient(HttpMessageHandler messageHandler)
    {
        _client = new HttpClient(messageHandler);
    }
}

如果您查看上面的类,我认为这就是您想要的。微软建议保持客户端处于活动状态以获得最佳性能,因此这种类型的结构允许您这样做。同时,HttpMessageHandler 是一个抽象类,因此可以进行模拟。您的测试方法将如下所示:

[TestMethod]
public void TestMethod1()
{
    // Arrange
    var mockMessageHandler = new Mock<HttpMessageHandler>();
    // Set up your mock behavior here
    var auroraClient = new AuroraClient(mockMessageHandler.Object);
    // Act
    // Assert
}

这使您能够在模拟HttpClient行为的同时测试自己的逻辑。

抱歉,各位,在我写完并尝试后,我意识到您无法模拟HttpMessageHandler上受保护的方法。因此,我随后添加了以下代码,以允许正确模拟的注入。

public interface IMockHttpMessageHandler
{
    Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken);
}

public class MockHttpMessageHandler : HttpMessageHandler
{
    private readonly IMockHttpMessageHandler _realMockHandler;

    public MockHttpMessageHandler(IMockHttpMessageHandler realMockHandler)
    {
        _realMockHandler = realMockHandler;
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        return await _realMockHandler.SendAsync(request, cancellationToken);
    }
}

使用这个方法编写的测试看起来像以下内容:

[TestMethod]
public async Task GetProductsReturnsDeserializedXmlXopData()
{
    // Arrange
    var mockMessageHandler = new Mock<IMockHttpMessageHandler>();
    // Set up Mock behavior here.
    var client = new AuroraClient(new MockHttpMessageHandler(mockMessageHandler.Object));
    // Act
    // Assert
}

1
这个答案的第一部分是正确的,使用HttpMessageHandler。这里有一个很好的博客和示例,https://gingter.org/2018/07/26/how-to-mock-httpclient-in-your-net-c-unit-tests/。Moq现在确实可以让你模拟和测试受保护的方法。 - Jason

10

我的一位同事注意到,HttpClient 的大多数方法都在幕后调用 HttpMessageInvoker 上的虚拟方法 SendAsync(HttpRequestMessage request, CancellationToken cancellationToken):

因此,目前最简单的模拟 HttpClient 的方法是简单地模拟该特定方法:

var mockClient = new Mock<HttpClient>();
mockClient.Setup(client => client.SendAsync(It.IsAny<HttpRequestMessage>(), It.IsAny<CancellationToken>())).ReturnsAsync(_mockResponse.Object);

你的代码可以调用大多数(但不是全部)HttpClient类的方法,包括一个常规的

httpClient.SendAsync(req)

勾选此处以确认:https://github.com/dotnet/corefx/blob/master/src/System.Net.Http/src/System/Net/Http/HttpClient.cs


1
这种方式对于直接调用SendAsync(HttpRequestMessage)的任何代码都不起作用。如果您可以修改代码以避免使用这个便利函数,那么直接通过覆盖“SendAsync”来模拟HttpClient实际上是我发现最干净的解决方案。 - Dylan Nicholson
以前是这样的,但我认为随着当前的变化,模拟内部 HttpHandler 并添加到服务中并设置模拟如何返回 SendAsync 方法会更容易。 - Bhanu Chhabra

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