65.9K
CodeProject 正在变化。 阅读更多。
Home

FakeHttp

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.84/5 (17投票s)

2015年7月12日

CPOL

13分钟阅读

viewsIcon

43655

伪造 HTTP 响应消息,以解耦客户端单元测试与服务实现。

引言

编写单元测试代码时,尽可能地隔离被测试代码至关重要,以确保测试的原子性。单元测试依赖的事物越多,其成功或失败就越可能与测试的意图无关。

例如,在编写动态 REST 客户端时,我创建了一些使用必应位置 REST API的单元测试。这些测试偶尔会失败,但当我立即再次运行时,它们就会成功。这非常难以排查,最终原因是必应服务在非常繁忙且无事可做时响应的方式。这最终促使我创建了这个库,以便我可以模拟服务响应,确保被测试的是被测代码本身,而不是其他任何东西。

现在,创建 HTTP 客户端并不是我们每天都会做的事情,但编写客户端 REST 服务包装器却相当普遍。这个库应该有助于单元测试任何使用System.Net.Http.HttpClient组件的代码。它旨在对现有代码的侵入性最小,如下文所述,这使得在不重大修改已编写组件或测试的情况下,可以相对容易地模拟 HTTP 流量。

背景

模拟本身是通过一个HttpMessageHandler实现的,组件为了与 FakeHttp 兼容,唯一需要确保的是,在实例化 HttpClient 时,它接受而不是创建处理程序。这可以通过工厂方法或对象、您选择的 IoC 容器或简单的构造函数参数来处理。

所以,代码可能看起来像这样

public class GeoCoder : IGeoCoder
{
    private readonly HttpClient _httpClient;

    public GeoCoder()
    {
        _httpClient = new HttpClient(new HttpClientHandler(), true);
        _httpClient.BaseAddress = 
             new Uri("http://dev.virtualearth.net/REST/v1/", UriKind.Absolute);
    }

    ...
}

应该看起来像这样:

public class GeoCoder : IGeoCoder
{
    private readonly HttpClient _httpClient;

    public GeoCoder(HttpMessageHandler handler)
    {
        _httpClient = new HttpClient(handler, false); // flag controls disposal of the handler 
        _httpClient.BaseAddress = new Uri("http://dev.virtualearth.net/REST/v1/", 
                                           UriKind.Absolute);
    }

    ...
}

完成这些之后,上面的代码就可以从单元测试或 IoC 容器传递一个 FakeHttpMessageHandler,它不会知道自己并没有连接到服务。FakeHttpMessageHandler 是一个相当简单的对象,它唯一的作用就是绕过网络并从其他存储中获取响应消息。

/// <summary>
/// A <see cref="System.Net.Http.HttpMessageHandler"/> that retrieves 
/// http response messages from
/// an alternate storage rather than from a given http endpoint
/// </summary>
public sealed class FakeHttpMessageHandler : HttpMessageHandler
{
    private readonly IReadonlyResponseStore _store;

    /// <summary>
    /// ctor
    /// </summary>
    /// <param name="store">The storage mechanism for responses</param>
    public FakeHttpMessageHandler(IReadonlyResponseStore store)
    {
        _store = store;
    }

    /// <summary>
    /// Override the base class to skip http and retrieve message from storage
    /// </summary>
    /// <param name="request"></param>
    /// <param name="cancellationToken"></param>
    /// <returns>The stored response message</returns>
    protected async override Task<HttpResponseMessage> 
              SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        return await _store.FindResponse(request);
    }
}

响应存储

默认情况下,响应存储在本地文件系统中,其文件夹结构反映了终端 URL。所以,终端 http://dev.virtualearth.net/REST/v1/Locations 将存储在一个类似于以下的文件夹中:d:\Users\Don\Documents\GitHub\FakeHttp\FakeResponses\dev.virtualearth.net\REST\v1\Locations。(注意:目前,没有逻辑来确保 Uri 路径可以表示为有效的文件路径。实际上,这通常是这样,所以我不需要进行任何字符映射,除了将斜杠替换为反斜杠)。

对于伴随必应位置服务的示例单元测试,您将看到这些文件。

每个响应有一个或两个文件,一个用于响应本身(HttpResponseMessage 的序列化版本),另一个用于响应内容。响应被序列化为 json,而内容则直接从服务作为流序列化,文件扩展名从响应的内容类型派生。大多数时候,响应内容是 JSON,但也支持 XML、HTML 或其他内容类型。

任何给定的模拟终端都必须有一个、两者或两者都没有这些文件。如果响应头不重要,则只需提供一个内容文件。在这种情况下,响应将返回 OK 并附带内容,而响应头集合为空。当检索并重构响应时,这两个文件代表了 FakeHttpMessageHandler 返回的内容。内容文件将完全按照接收到的方式序列化,而序列化响应是一个 json 文件,可能看起来像这样。

{
  "StatusCode": 200,
  "Query": "c=en-us&countryregion=us&maxres=1&postalcode=55116",
  "ContentFileName": "GET.8F8BE39FAED23347CB3B40A0053E1EA46644AB9C.content.json",
  "ResponseHeaders": {
    "Transfer-Encoding": [
      "chunked"
    ],
    "X-BM-TraceID": [
      "d2363ee13d844c9cbaa2db37abf6688b"
    ],
    "X-BM-Srv": [
      "BN20121762, BN2SCH020180739"
    ],
    "X-MS-BM-WS-INFO": [
      "0"
    ],
    "Cache-Control": [
      "no-cache"
    ],
    "Date": [
      "Mon, 06 Jul 2015 20:18:09 GMT"
    ],
    "Server": [
      "Microsoft-IIS/8.0"
    ],
    "X-AspNet-Version": [
      "4.0.30319"
    ],
    "X-Powered-By": [
      "ASP.NET"
    ]
  },
  "ContentHeaders": {
    "Content-Type": [
      "application/json; charset=utf-8"
    ]
  }
}

每个响应的文件名是根据终端和用于调用它的 URL 参数确定性生成的。文件名的格式如下:“HTTP 动词”。“SHA1 哈希”。“(response | content)” 如果终端不需要 URL 参数,SHA1 哈希将为空,生成一个像“GET.response.json”这样的名称。

参数哈希

为了正常工作,给定的终端路径、动词和查询参数组合始终需要产生相同的响应。哈希假设参数不区分大小写,也不依赖于顺序。其实现位于链接代码的 MessageFormatter 类中,但相对简单。

  1. 过滤掉用户定义的忽略列表参数(见下文)。
  2. 按字母顺序对参数进行排序。
  3. 将它们连接成一个单一的 string,采用规范化的 Uri 参数列表格式。
  4. 将该字符串转换为 ToLower
  5. 对结果进行 SHA1 哈希。

有些服务接受请求体中的参数,而不是在 Uri 中,或者作为 Uri 查询参数和请求体数据的组合。目前不支持请求体参数化,但已列入待办事项。

Using the Code

只要被测代码接受 HttpMessageHandler,其他一切都由单元测试方完成。被测代码不需要引用 FakeHttp 类型或程序集。在下面的示例中,我使用的是 MSTest。同样的概念也适用于其他单元测试框架,尽管具体机制会有所不同。我还使用了 MVVM Light 库中的 SimpleIoC 容器。同样的概念也适用于其他容器。

自动响应管理

使用 FakeHttp 最快的方法是,在实例化 HttpClient 的任何地方都使用 AutomaticHttpClientHandler。此处理程序会首先检查每个请求是否在本地存储了响应。如果存在,则返回响应。如果找不到本地响应,处理程序将联系实际终端。然后存储响应并将其返回给客户端。要强制刷新响应数据,只需删除本地版本,它们就会从在线终端获取。

[TestMethod]
public async Task CanAccessGoogleStorageBucket()
{
    // this is the path where responses will be stored for future use
    var path = Path.Combine(Path.GetTempPath(), "FakeHttp_UnitTests");
    
    var handler = new AutomaticHttpClientHandler(new FileSystemResponseStore(path));
    
    using (var client = new HttpClient(handler, true))
    {
        client.BaseAddress = new Uri("https://www.googleapis.com/");
        using (var response = await client.GetAsync("storage/v1/b/uspto-pair"))
        {
            response.EnsureSuccessStatusCode();

            dynamic metaData = await response.Content.Deserialize<dynamic>();

            // we got a response and it looks like the one we want
            Assert.IsNotNull(metaData);
            Assert.AreEqual("https://www.googleapis.com/storage/v1/b/uspto-pair", 
                             metaData.selfLink);
        }
    }
}

管理响应文件

也可以显式控制是使用本地还是在线版本的响应。

由于响应被序列化到文件系统,因此有必要使单元测试能够访问它们。这可以通过几种方式完成:将它们作为单元测试项目的“内容”文件,将路径存储在配置文件中,使用环境变量、硬编码路径或任何其他方法。我选择了一种方法,即在解决方案的同一目录中创建一个文件夹,并结合使用预构建事件和 MSTest 的 DeploymentItem 属性。这种方法相对简单,并且在添加和删除响应和测试时不需要持续的管理。

  1. 在解决方案 .sln 文件所在的目录中创建一个文件夹(在我们的示例中为 FakeResponses),用于存放序列化的响应。
  2. 为单元测试项目添加一个预构建事件,该事件会将模拟响应复制到测试程序集输出文件夹。
    del /f /s /q "$(TargetDir)FakeResponses\"
    xcopy /Y /S /Q "$(SolutionDir)FakeResponses\*" "$(TargetDir)FakeResponses\"
  3. 确保所有测试类都带有 [DeploymentItem(@"FakeResponses\")] 装饰。这可以放在测试方法或类级别,但放在每个测试类上更容易。此属性会告知 MSTest 将步骤 2 中已复制到构建输出文件夹的 FakeResponses 文件夹复制到测试将要执行的任何位置。这样,它在 VS.NET 和构建服务器上都能正常工作。

一个最小化的示例

将 NuGet 包的引用添加到您的单元测试程序集后,要设置一个最小化的测试终端,请在 FakeResponses 文件夹中创建与终端路径匹配的文件夹结构。我们将使用一个假定的终端 http://www.example.com/HelloWorldService,它将位于一个可能看起来像这样的路径中:d:\Users\Don\Documents\GitHub\FakeHttp\FakeResponses\www.example.com\HelloWorldService

然后,我们将一个非常简单的内容 json 文件放入该文件夹。它将命名为 GET.content.json,因为在此终端将不会模拟任何查询参数。此外,由于我们不关心响应的任何具体细节,因此我们不会创建 response.json 文件。

{
	"Message": "Hello World"
}

设置好模拟终端后,我们就可以对其进行测试了。

[TestClass]
[DeploymentItem(@"FakeResponses\")]
public class ExampleTests
{
    public TestContext TestContext { get; set; }

    [TestMethod]
    public async Task MinimalExampleTest()
    {
        var handler = new FakeHttpMessageHandler
                      (new FileSystemResponseStore(TestContext.DeploymentDirectory));
        using (var client = new HttpClient(handler, true))
        {
            client.BaseAddress = new Uri("https://www.example.com/");
            var response = await client.GetAsync("HelloWorldService");
            response.EnsureSuccessStatusCode();

            dynamic content = await response.Content.Deserialize<dynamic>();

            Assert.IsNotNull(content);
            Assert.AreEqual("Hello World", content.Message);
        }
    }
}

当然,该测试确实是模拟的,但它展示了所需的最小连接量。

设置捕获和回放

对于大量单元测试或支持捕获和回放,处理程序的设置应移至一些初始化方法。第一步是设置一个 IoC 容器,以便所有单元测试可以使用相同的消息处理程序(注意:在这些简单的示例中,IoC 并非真正必要,但在更复杂的场景中会更有价值)。使用 MSTest,我们将利用其标记方法的属性来在单元测试运行一次。

[AssemblyInitialize]
public static void AssemblyInitialize(TestContext context)
{
    // setup IOC so test classes can get the shared message handler
    ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);

    // folders where fake responses are stored and where captured response should be saved
    var fakeFolder = context.DeploymentDirectory;  // the folder where 
                                                   // the unit tests are running
    var captureFolder = 
        Path.Combine(context.TestRunDirectory, @"..\..\FakeResponses\"); // kinda hacky 
                                                   // but this should be the solution folder

    // here, we don't want to serialize or include our API key in response lookups so
    // pass a lambda that will indicate to the serializer to filter that param out
    var store = new FileSystemResponseStore(fakeFolder, captureFolder, 
      (name, value) => name.Equals("key", StringComparison.InvariantCultureIgnoreCase));

    // set the http message handler factory to the mode we want for the 
    // entire assembly test execution
    MessageHandlerFactory.Mode = MessageHandlerMode.Fake;
    SimpleIoc.Default.Register<HttpMessageHandler>
               (() => MessageHandlerFactory.CreateMessageHandler(store));
}

[AssemblyCleanup]
public static void AssemblyCleanup()
{
    if (SimpleIoc.Default.IsRegistered<HttpMessageHandler>())
    {
        SimpleIoc.Default.GetInstance<HttpMessageHandler>().Dispose();
    }
}

每个单元测试程序集只能有一个 AssemblyInitializeAssemblyCleanup。上面的初始化方法执行以下操作:

  1. 设置 IoC 容器。
  2. 确定测试执行期间序列化响应的存储位置,以及捕获时序列化响应的位置。
  3. 设置一个知道如何存储和检索响应的存储对象。
  4. 配置执行模式。
  5. HttpMessageHandler 类型注册一个工厂方法(请参阅下面的执行模式)。

对于示例,必应位置单元测试的每个测试类都有一个服务包装器的实例,该实例在测试类实例化时进行设置,从 IoC 容器中获取 HttpMessageHandler 并创建一个 GeoCoder 服务包装器。

[TestClass]
[DeploymentItem(@"FakeResponses\")]
public class AddressPartTests
{
    private static IGeoCoder _service;

    [ClassInitialize]
    public static void ClassInitialize(TestContext context)
    {
        var handler = SimpleIoc.Default.GetInstance<HttpMessageHandler>();

        _service = new GeoCoder(handler, CredentialStore.RetrieveObject("bing.key.json").Key, 
                   "Portable-Bing-GeoCoder-UnitTests/1.0");
    }

    [ClassCleanup]
    public static void ClassCleanup()
    {
        if (_service != null)
        {
            _service.Dispose();
        }
    }
    
    ...
    
}

执行模式

那么,如何制作序列化的响应和内容文件呢?当然,这些文件都可以用任何纯文本编辑器手动制作,但这至少可以说是乏味的。为了简化这个过程,FakeHttp 支持 HTTP 流量的记录和回放。基本模式是:

  1. 编写一个命中实际服务终端的单元测试。
  2. 执行该单元测试,捕获响应和内容,并将其序列化到磁盘。
    • 可选地,手动修改序列化的响应或内容文件,以模拟所需的特定测试条件。
  3. 执行未来的测试,使用捕获的响应文件而不是服务响应。

为了方便地在这两种模式之间切换,有一个静态的 MessageHandlerFactory 类,可以指示它以三种模式之一运行:

自动

在自动模式下,CreateMessageHandler 将返回 AutomaticHttpClientHandler 的实例。此处理程序将在本地缓存响应时自动存储和返回响应。如果本地没有缓存,它将联系实际终端。

在线

在在线模式下,CreateMessageHandler 将返回一个标准的 HttpClientHandler 实例。单元测试代码将直接与 Uri 另一端的任何服务进行交互。这与您通常使用 HttpClient 没有区别,并且没有 FakeHttp 对象参与通信。

捕捉

在捕获模式下,会创建一个处理程序,它仍然会与服务终端通信,但在返回响应之前,它会将响应及其内容序列化到文件系统以供将来使用。之后,您可以编辑 JSON,也许更改响应状态码,添加或删除标头,或修改内容文件以包含与服务响应不同的特定值或数据。

信息泄露警告:请注意,响应和内容是按原样序列化到您指定的任何文件夹的。这确实存在泄露信息的风险。如果服务返回个人或敏感数据,它被写入磁盘,因此请根据您所处理的服务性质谨慎行事。如果存在不应在捕获服务器响应时保存的信息,请参阅下面的关于屏蔽数据的部分。

Fake

这是您的单元测试花费大部分时间运行的模式。在此模式下,不会与服务建立联系,并且响应将从在 Capture 模式下存储的内容反序列化。

现在,确定单元测试如何运行的逻辑已经设置好,单元测试本身变得非常直接;只关注被测代码和预期的测试结果。如果由于服务行为的改变等原因,有必要执行针对实际服务的测试,只需切换到 Online 模式即可轻松完成。

[TestMethod]
public async Task GetNeighborhoodFromCoordinate()
{
    var address = await _service.GetAddressPart
                  (44.9108238220215, -93.1702041625977, "Neighborhood");

    Assert.AreEqual("Highland", address);
}

在运行时控制假响应

在某些情况下,您可能需要更细粒度地控制响应的序列化、反序列化和索引方式。为此,在创建响应存储时可以提供一个回调接口。

public interface IResponseCallbacks
{
    Task<Stream> Deserialized(ResponseInfo info, Stream content);

    Task<Stream> Serializing(HttpResponseMessage response);

    bool FilterParameter(string name, string value);
}

参数过滤

某些查询参数可能不希望包含在文件名哈希中。API 密钥是一个很好的例子。您不希望对其进行哈希处理,因为它们不是终端本身的一部分。此外,它们可能会因开发者而异,您也不希望假响应与特定开发者或团队绑定。关于 API 密钥的另一个要点是,所有查询参数都会在响应 json 中进行序列化。如果您在响应序列化中包含 API 密钥,如果无意中共享了序列化响应,它们可能会泄露您的控制。

因此,存在一种机制,可以通过该机制将 API 密钥参数或其他临时参数类型排除在哈希和序列化之外。传递给参数名称和值的 FilterParameter 可用于抑制参数。对于应被过滤掉的任何参数,请从此函数返回 true

序列化前屏蔽数据

由于响应被序列化到磁盘,因此需要注意,从服务返回的任何敏感数据都可能以明文形式显示。例如,如果您正在测试包含信用卡号或社会安全号码的服务终端,您不希望将它们存储在磁盘上。Serializing 方法在保存响应之前被调用,并允许调用代码提供替代的响应流。

public async override Task<Stream> Serializing(HttpResponseMessage response)
{
    if (response.RequestMessage.RequestUri.Host == "www.googleapis.com")
    {
        // get the service content
        var result = await response.Content.Deserialize<dynamic>();

        // modify it
        result.storageClass = "THIS VALUE MASKED";

        // serialize and return a new stream which will be written to disk
        var json = JsonConvert.SerializeObject(result);
        return new MemoryStream(Encoding.UTF8.GetBytes(json));
    }

    return await base.Serializing(response);
}

反序列化后修改响应

最后,有时可能需要为特定测试设置时间戳或其他临时敏感值。Deserialized 方法在响应和内容被重新水化后立即被调用,并允许在将它们返回给 HttpClient 之前修改两者。

public async override Task<Stream> Deserialized(ResponseInfo info, Stream content)
{
    if (info.ResponseHeaders.ContainsKey("Date"))
    {
        info.ResponseHeaders["Date"] = 
             new List<string>() { DateTimeOffset.UtcNow.ToString("r") };
    }
    return await base.Deserialized(info, content);
}

关注点

响应模拟在测试客户端代码如何响应故障条件或服务中不常见的响应逻辑时特别有价值。这尤其适用于您不控制且无法请求返回失败消息的终端或实例的服务。

回到必应位置服务,当服务器过载时,它不会返回 ServiceUnavailableRequestTimeout 状态码。必应位置服务返回 OK,一个有效但空的 JSON 响应,并插入响应头“X-MS-BM-WS-INFO”和值 1。这表明服务很忙,但您可以重试请求。通过模拟,可以可靠地单元测试重试逻辑。

这只是一个依赖于服务响应的客户端逻辑示例,可以在不需要服务器端强制响应的情况下进行测试。我相信还有其他类似的例子。

使用模拟来处理此类事情的另一个好处是,单元测试的速度会大大提高。根据我的经验,需要数百毫秒往返网络才能完成的测试,使用模拟只需不到 10 毫秒。对于大量的测试来说,这使得执行时间可以经常且始终如一地运行。

历史

  • 2015年7月12日 - 初始版本
  • 2015年7月24日 - 添加了关于回调的部分(v1.1.0)
  • 2015年10月25日 - 添加了关于自动模式的部分
© . All rights reserved.