使用 Goldlight.HttpClientTestSupport 轻松测试 HttpClient






4.90/5 (5投票s)
使用 Goldlight.HttpClientTestSupport 轻松测试 HttpClient 的方法
场景
我们有一系列单元测试,在这些测试中,我们想测试 HttpClient
的一个请求,但本着真正的隔离测试的精神,我们不想实际执行 HTTP 请求。我们需要一种方法来用一个假冒的终结点替换对真实终结点的调用。
在这种情况下,问题在于似乎没有简单的方法来模拟 HttpClient
。没有可用的 IHttpClient
接口,允许我们设置模拟,并且我们感兴趣的调用不是虚方法,因此在大多数模拟框架中很难模拟它们。考虑到这种明显的限制,我们如何轻松地模拟 PostAsync
等调用?
为了回答这个问题,我们需要了解 HttpClient
实际如何管理 HTTP 请求。如果我们查看 参考源 中 GetAsync
的源代码,我们会发现该方法调用 SendAsync
。
public Task<HttpResponseMessage> GetAsync
(Uri requestUri, HttpCompletionOption completionOption,
CancellationToken cancellationToken)
{
return SendAsync(new HttpRequestMessage(HttpMethod.Get, requestUri),
completionOption, cancellationToken);
}
HttpClient
继承自一个名为 HttpMessageInvoker 的类,该类在其内部有一个虚方法 SendAsync
。HttpMessageInvoker
类需要通过构造函数传递一个 HttpMessageHandler
实例。这一点很重要,因为 SendAsync
调用 HttpMessageHandler.SendAsync
方法,这是我们希望与之交互以模拟 REST 调用时所用的“触点”。
模拟 Message Handler
关于测试 message handler,我们必须应对的一个问题是,虚方法 SendAsync
是受保护的,并且大多数模拟框架都无法模拟虚方法。虽然 Moq 提供了模拟 protected
属性的功能,但如果我们使用的是 FakeItEasy
等框架,则无法直接与 protected
方法进行交互。
所有这些侦探工作告诉我们,为了替换我们的调用,我们需要继承 HttpMessageHandler
并提供我们自己的实现,以满足模拟 Web 请求的能力。
我们不创建一个可模拟对象,而是提供一个 FakeHttpMessageHandler
,它允许我们控制接收到的响应包含什么。
安装 Goldlight.HttpClientTestSupport
Goldlight.HttpClientTestSupport
可作为 .NET Standard 2.0 和 .NET Standard 2.1 包在 NuGet 上提供,可以使用以下命令安装:
Install-Package Goldlight.HttpClientTestSupport
使用 dotnet 命令行
dotnet add package Goldlight.HttpClientTestSupport
基本用法
FakeHttpMessageHandler
实现会在实例化时做出某些假设。实例化时,期望我们的响应消息设置为 1.0 版本,状态代码默认为 200
。如果需要,我们可以使用流畅的 API 覆盖这些值。
我们将从一个简单的 xUnit 测试开始。在我们这里看到的所有示例中,我们都使用 xUnit,但任何测试框架都可以。我们的示例还将避免使用任何模拟框架,以展示我们如何轻松地测试代码。
[Fact]
public async Task EnsureOkStatusIsReturned()
{
HttpClient httpClient = new HttpClient(new FakeHttpMessageHandler());
HttpResponseMessage response = await httpClient.GetAsync("https://mydummy.url/");
Assert.Equals(HttpStatusCode.OK, response.StatusCode);
}
处理响应内容
显然,当我们处理 GET
调用时,我们确实希望看到一些内容返回。让我们看看如何设置一个测试来处理这个问题。
首先,我们要创建一个模型,我们将返回它。
public sealed class SampleModel
{
public string FirstName => "Stan";
public string LastName => "Lee";
public string FullName => FirstName + " " + LastName;
}
有了这个类,我们的测试会是什么样子?在这个测试中,我们看到了流畅的 API 在起作用,用我们的模型作为预期的响应内容设置了我们的假处理程序。
[Fact]
public async Task
GivenValidRequestWithModelContentExpected_WhenGetIsCalled_ThenContentIsSetToModel()
{
FakeHttpMessageHandler fake =
new FakeHttpMessageHandler().WithExpectedContent(new SampleModel());
HttpClient httpClient = new HttpClient(fake);
HttpResponseMessage responseMessage =
await httpClient.GetAsync("https://dummyaddress.com/someapi");
SampleModel converted =
JsonConvert.DeserializeObject<SampleModel>
(await responseMessage.Content.ReadAsStringAsync());
Assert.Equal("Stan Lee", converted.FullName);
}
返回不同的状态码
对于 GET
调用,我们应该测试一下当收到 404 Not Found 状态时我们的代码会发生什么。问题是,我们该如何设置?
我们将从创建一个更真实的示例开始。我们想看到我们的假消息处理程序在起作用。看到处理程序在单元测试之外使用它的最好方法是实际使用它。让我们创建一个可能被 Web 控制器调用的类。
public class ExampleControllerHandling
{
private readonly HttpClient _httpClient;
private const string BaseUrl = "http://www.goldlight-dummy.com/api/sample/";
public ExampleControllerHandling(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<SampleModel> GetById(Guid id)
{
HttpResponseMessage response = await _httpClient.GetAsync(BaseUrl + id);
switch (response.StatusCode)
{
case HttpStatusCode.OK:
return JsonConvert.DeserializeObject<SampleModel>
(await response.Content.ReadAsStringAsync());
case HttpStatusCode.BadRequest:
throw new Exception("Unable to find " + id);
}
return null;
}
}
让我们创建一个测试来测试我们代码中的 BadRequest
路径。
[Fact]
public async Task
GivenComplexController_WhenBadRequestIsExpected_ThenBadRequestIsHandled()
{
FakeHttpMessageHandler fake =
new FakeHttpMessageHandler().WithStatusCode(HttpStatusCode.BadRequest);
HttpClient httpClient = new HttpClient(fake);
ExampleControllerHandling exampleController =
new ExampleControllerHandling(httpClient);
int called = 0;
try
{
await exampleController.GetById(Guid.NewGuid());
}
catch (Exception e)
{
if (e.Message.StartsWith("Unable to find "))
{
called++;
}
}
Assert.Equal(1, called);
}
正如我们在代码示例中看到的,预期的状态码使用 WithStatusCode
调用添加到我们的处理程序中。
处理 Headers
我们的代码不得不处理响应头是很常见的。为了在实践中看到它是如何工作的,让我们向 ExampleControllerHandling
示例添加一个新方法,在状态码为 200 且我们有一个名为 order66
的响应头,其中包含值 babyyoda
时返回所有值的列表。
public async Task<IEnumerable<SampleModel>> GetAll()
{
HttpResponseMessage response = await _httpClient.GetAsync(BaseUrl);
if (response.StatusCode == HttpStatusCode.OK && response.Headers.TryGetValues
("order66", out IEnumerable<string> headers))
{
if (headers.First() == "babyyoda")
{
return JsonConvert.DeserializeObject<IEnumerable<SampleModel>>
(await response.Content.ReadAsStringAsync());
}
}
return null;
}
为了添加头,我们将使用 WithResponseHeader
添加一个键/值对作为响应头。正如我们所知,我们的消息处理程序可以用流畅的 API 来构建,我们将用一个链来添加多个部分。
[Fact]
public async Task GivenMultipleInputsIntoController_WhenProcessing_ThenModelIsReturned()
{
List<SampleModel> sample = new List<SampleModel>()
{new SampleModel(), new SampleModel()};
FakeHttpMessageHandler fake =
new FakeHttpMessageHandler().WithStatusCode(HttpStatusCode.OK)
.WithResponseHeader("order66", "babyyoda").WithExpectedContent(sample);
HttpClient httpClient = new HttpClient(fake);
ExampleControllerHandling exampleController =
new ExampleControllerHandling(httpClient);
IEnumerable<SampleModel> output = await exampleController.GetAll();
Assert.Equal(2, output.Count());
}
在这个示例中,我们可以看到我们显式设置了状态码(好吧,虽然它是默认值,但我想演示一次添加多个部分)以及我们期望触发响应的响应头。
除了可以为头设置单个值外,还有一个接受值数组的重载。
Trailing Headers
与设置响应头的方式相同,NetStandard2.1 让我们能够添加 trailing headers。此功能仅在支持 .NetStandard2.1 的目标(如 .NET Core 3.1 和 .NET 5)中可用。使用 WithTrailingResponseHeader
添加 trailing headers。
设置版本信息
如果我们需要为 REST 调用设置版本号,我们可以使用 WithVersion
来提供版本信息。
[Fact]
public async Task GivenValidRequestWithCustomVersion_WhenPostIsCalled_ThenCustomVersionIsSet()
{
FakeHttpMessageHandler fake =
new FakeHttpMessageHandler().WithVersion(new Version(2, 1));
HttpClient httpClient = new HttpClient(fake);
HttpResponseMessage response = await httpClient.PostWrapperAsync("MyContent");
Assert.Equal(response.Version, new Version(2, 1));
}
通过自己的测试扩展调用
在某些情况下,我们可能想要执行难以预测的附加测试。假设我们想验证我们调用 HttpClient
的次数,我们需要一些机制来执行此操作。我们可以提供一个 InvocationCount
属性,但这表示我们试图预测 API 的不同使用方式。我们选择不这样做,而是提供了添加调用前和调用后操作的功能。调用前处理程序在 SendAsync
方法中的第一个操作被调用,调用后处理程序在返回之前被调用。这里的第一个示例演示了使用 WithPreRequest
方法添加计算方法调用次数的功能。
[Fact]
public async Task GivenPreActionForController_WhenProcessing_ThenActionIsPerformed()
{
int invocationCount = 0;
List<SampleModel> sample = new List<SampleModel>()
{ new SampleModel(), new SampleModel() };
FakeHttpMessageHandler fake =
new FakeHttpMessageHandler().WithPreRequest(() => invocationCount++)
.WithExpectedContent(sample);
HttpClient httpClient = new HttpClient(fake);
ExampleControllerHandling exampleController =
new ExampleControllerHandling(httpClient);
IEnumerable<SampleModel> output = await exampleController.GetAll();
Assert.Equal(1, invocationCount);
}
我们可以使用 WithPostRequest
方法执行完全相同的调用计算。
[Fact]
public async Task GivenPostActionForController_WhenProcessing_ThenActionIsPerformed()
{
int invocationCount = 0;
List<SampleModel> sample = new List<SampleModel>()
{ new SampleModel(), new SampleModel() };
FakeHttpMessageHandler fake =
new FakeHttpMessageHandler().WithPostRequest(() => invocationCount++)
.WithExpectedContent(sample);
HttpClient httpClient = new HttpClient(fake);
ExampleControllerHandling exampleController =
new ExampleControllerHandling(httpClient);
IEnumerable<SampleModel> output = await exampleController.GetAll();
Assert.Equal(1, invocationCount);
}
我们可以有多个调用前和调用后请求操作。在下面的示例中,我们将故意从调用前处理程序中抛出一个异常,验证调用前处理程序已执行,并检查以确保由于异常,调用后处理程序未被达到。
[Fact]
public async Task GivenPreAndPostActionForController_WhenProcessing_ThenActionIsPerformed()
{
int invocationCount = 0;
int postInvocationCount = 0;
List<SampleModel> sample = new List<SampleModel>()
{ new SampleModel(), new SampleModel() };
FakeHttpMessageHandler fake = new FakeHttpMessageHandler().WithPreRequest(() =>
invocationCount++)
.WithPreRequest(() => throw new Exception
("Throwing deliberately"))
.WithPostRequest(() => postInvocationCount++)
.WithExpectedContent(sample);
HttpClient httpClient = new HttpClient(fake);
ExampleControllerHandling exampleController = new ExampleControllerHandling(httpClient);
IEnumerable<SampleModel> output = await exampleController.GetAll();
Assert.Equal(1, invocationCount);
Assert.Equal(0, postInvocationCount);
}