Back to "无鼠标 HttpClient 单位测试 HttpClient"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

HttpClient Unit Testing xUnit

无鼠标 HttpClient 单位测试 HttpClient

Saturday, 29 November 2025

一. 导言 导言 导言 导言 导言 导言 一,导言 导言 导言 导言 导言 导言

当测试使用代码时 HttpClient传统方法涉及嘲弄 HttpMessageHandler 使用像 Moq 这样的框架。 虽然这有效, 但它可以是动词, 仪式重, 坦率地说, 有点丑陋。 还有一个更干净的替代方案: 使用 。 DelegatingHandler 创建测试处理器,使其行为像真正的 HTTP 端点 。

在这个职位上,我要向大家说明,为什么你们会完全跳过模拟,完全使用 DelegatingHandler 用于更可读、可维持和紧凑的测试代码。

模拟 HttpMessageHandler 的问题

这就是典型的 HttpMessageHandler 嘲笑像莫克人:

var mockHandler = new Mock<HttpMessageHandler>();
mockHandler.Protected()
    .Setup<Task<HttpResponseMessage>>(
        "SendAsync",
        ItExpr.Is<HttpRequestMessage>(x => x.RequestUri.ToString().Contains("api/send")),
        ItExpr.IsAny<CancellationToken>())
    .ReturnsAsync((HttpRequestMessage request, CancellationToken cancellationToken) =>
    {
        var requestBody = request.Content?.ReadAsStringAsync(cancellationToken).Result;
        return new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent(requestBody ?? "No content", Encoding.UTF8, "application/json")
        };
    });

var client = new HttpClient(mockHandler.Object);

这有几个问题:

  1. 柔柔 - 有很多锅炉板板板板板板板板板板板板板板板板板板板板板板板板板的简单行为应该简单
  2. 保护方法仪式 - 你需要 - 你需要 Protected()ItExpr 原因原因 SendAsync 受保护
  3. 难读 - 实际测试逻辑被埋在设置仪式中
  4. 不可再使用 - 每次测试都需要类似的设置代码
  5. 饼干 - 容易弄错基于字符串的方法名称

杰出汉人替代方案

DelegatingHandler 是一个内置的. NET 类, 设计就是为了这个目的 在HTTP 请求进入网络之前截取它们的请求。 这是在生产中使用的中间软件, 比如重试处理器、 伐木处理器和认证处理器。

这里的功能相同 使用 DelegatingHandler:

public class EchoHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var content = request.Content != null
            ? await request.Content.ReadAsStringAsync(cancellationToken)
            : "No content";

        return new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent(content, Encoding.UTF8, "application/json")
        };
    }
}

使用:

var client = new HttpClient(new EchoHandler());

就是这样,没有模拟框架,没有受保护的方法体操,没有基于字符串的方法名称。

真实世界实例:翻译服务处理器

以下是翻译服务测试处理器更精密的例子:

public class TranslateDelegatingHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var absPath = request.RequestUri?.AbsolutePath;
        var method = request.Method;

        return absPath switch
        {
            "/translate" when method == HttpMethod.Post => await HandleTranslate(request),
            "/translate" => new HttpResponseMessage(HttpStatusCode.OK),
            "/health" => new HttpResponseMessage(HttpStatusCode.OK),
            _ => new HttpResponseMessage(HttpStatusCode.NotFound)
        };
    }

    private static async Task<HttpResponseMessage> HandleTranslate(HttpRequestMessage request)
    {
        var content = await request.Content!.ReadFromJsonAsync<TranslateRequest>();

        // Simulate error for specific test case
        if (content?.TargetLanguage == "xx")
            return new HttpResponseMessage(HttpStatusCode.InternalServerError);

        var response = new TranslateResponse("es", new[] { "Texto traducido" });
        return new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = JsonContent.Create(response)
        };
    }
}

此处理器 :

  • 选择不同行为的不同途径
  • 将决策请求内容降为请求内容
  • 为具体情景返回适当的错误代码
  • 完全可读和自我文件

使用依赖性注射设置

使用时使用 IHttpClientFactory 将试验处理器整合起来是简单明了的:

public static IServiceCollection SetupTestServices(DelegatingHandler handler)
{
    var services = new ServiceCollection();

    services.AddHttpClient<ITranslationService, TranslationService>(client =>
    {
        client.BaseAddress = new Uri("https://test.local");
    })
    .ConfigurePrimaryHttpMessageHandler(() => handler);

    return services;
}

然后在你的测试中:

[Fact]
public async Task Translate_ReturnsTranslatedText()
{
    var services = SetupTestServices(new TranslateDelegatingHandler());
    var provider = services.BuildServiceProvider();
    var service = provider.GetRequiredService<ITranslationService>();

    var result = await service.TranslateAsync("Hello", "es");

    Assert.Equal("Texto traducido", result);
}

[Fact]
public async Task Translate_InvalidLanguage_ThrowsException()
{
    var services = SetupTestServices(new TranslateDelegatingHandler());
    var provider = services.BuildServiceProvider();
    var service = provider.GetRequiredService<ITranslationService>();

    await Assert.ThrowsAsync<HttpRequestException>(
        () => service.TranslateAsync("Hello", "xx"));
}

高级模式:可配置处理器

为了更具灵活性,您可以创建能够接受配置的处理器:

public class ConfigurableHandler : DelegatingHandler
{
    private readonly Dictionary<string, Func<HttpRequestMessage, Task<HttpResponseMessage>>> _routes;

    public ConfigurableHandler()
    {
        _routes = new Dictionary<string, Func<HttpRequestMessage, Task<HttpResponseMessage>>>();
    }

    public ConfigurableHandler WithRoute(string path, HttpStatusCode status)
    {
        _routes[path] = _ => Task.FromResult(new HttpResponseMessage(status));
        return this;
    }

    public ConfigurableHandler WithRoute(string path, object responseBody)
    {
        _routes[path] = _ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = JsonContent.Create(responseBody)
        });
        return this;
    }

    public ConfigurableHandler WithRoute(
        string path,
        Func<HttpRequestMessage, Task<HttpResponseMessage>> handler)
    {
        _routes[path] = handler;
        return this;
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var path = request.RequestUri?.AbsolutePath ?? "";

        if (_routes.TryGetValue(path, out var handler))
            return await handler(request);

        return new HttpResponseMessage(HttpStatusCode.NotFound);
    }
}

用法 :

var handler = new ConfigurableHandler()
    .WithRoute("/api/users", new[] { new User("Alice"), new User("Bob") })
    .WithRoute("/api/health", HttpStatusCode.OK)
    .WithRoute("/api/error", HttpStatusCode.InternalServerError);

var client = new HttpClient(handler);

为什么要选择莫克斯的统治者汉德勒?

以摩克為基礎的嘲笑 提名漢德勒

-------- ------------------- -------------------
可易读性 低(礼仪重) 高(仅C#)
可再性 可怜的 棒极了
除调调 用力点(模仿魔术) 轻点(一步步通过)
重构
学习曲线 斯迪普(Moq APIs) Minimal
依赖性 需要Moq (内建)

当嘲笑仍然让人发人深思时

公平而言,Moq式的嘲笑可能仍然适当:

  1. 一次性的简单答复 - 如果你需要一次单反应处理器 内线莫克可能更快
  2. 核查 - 摩克的 Verify() 发出呼吁是有用的
  3. 现有代码库 - 如果你的团队已经拥有了庞大的Moq基础设施

用于核查,您也可以将其添加到Handler代表处:

public class VerifyingHandler : DelegatingHandler
{
    public List<HttpRequestMessage> ReceivedRequests { get; } = new();

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        ReceivedRequests.Add(request);
        return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
    }
}

结论 结论 结论 结论 结论

使用 DelegatingHandler HttpClient 测试给您提供 :

  • 契约守则 - 不举行嘲弄框架仪式
  • 可读测试 - 只是普通的C#班
  • 可再使用处理器 - 不同测试班级的共享比例
  • 容易调试 - 设置断点,通过代码
  • 零依赖性 - 它嵌入了. NET

下次你再伸手找 Mock<HttpMessageHandler>,考虑是否简单的 DelegatingHandler 你的未来自己(和你的队友)会感谢你们 提供了更干净、更适合维护的测试代码

有关这种模式的实际实例,请见这一解决办法中的试验项目。

logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.