Back to "Unità di prova HttpClient SENZA Mock"

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

Unità di prova HttpClient SENZA Mock

Saturday, 29 November 2025

Introduzione

Quando si testa il codice che utilizza HttpClient, l'approccio tradizionale comporta la derisione HttpMessageHandler usando framework come Moq. Mentre questo funziona, può essere verbose, cerimonia-pesante, e francamente un po 'brutto. C'è un'alternativa più pulita: utilizzando DelegatingHandler per creare soggetti che si comportano come veri e propri endpoint HTTP.

In questo post vi mostrerò perché si potrebbe saltare i morsi del tutto e utilizzare DelegatingHandler per un codice di prova più leggibile, manutenibile e compatto.

Il problema con Mocking HttpMessageHandler

Ecco il tipico HttpMessageHandler scherzo sembra come con Moq:

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

Questo ha diverse questioni:

  1. Verboso - Un sacco di piastra caldaia per quello che dovrebbe essere semplice comportamento
  2. Cerimonia di metodi protetti - Hai bisogno di Protected() e ItExpr perché SendAsync è protetto
  3. Difficile da leggere - La logica del test è sepolta in una cerimonia di messa a punto
  4. Non riutilizzabile - Ogni test ha bisogno di un codice di configurazione simile
  5. BrittleCity name (optional, probably does not need a translation) - Facile sbagliare il nome del metodo basato sulle stringhe

L'alternativa del delegatoHandler

DelegatingHandler è una classe .NET integrata progettata proprio per questo scopo - intercettare le richieste HTTP prima di colpire la rete. E 'quello che middleware come i gestori di riprova, i gestori di registrazione e i gestori di autenticazione utilizzano nella produzione.

Qui c'è la stessa funzionalità usando 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")
        };
    }
}

Usandolo:

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

Niente quadri di derisione, niente ginnastica di metodo protetta, niente nomi di metodi basati su stringhe.

Un esempio reale del mondo: gestore del servizio di traduzione

Ecco un esempio più sofisticato da un gestore di test di servizio di traduzione:

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

Questo responsabile:

  • Percorsi diversi percorsi a diversi comportamenti
  • Deserializza la richiesta di contenuti per prendere decisioni
  • Restituisce codici di errore appropriati per scenari specifici
  • È completamente leggibile e auto-documentante

Impostazione con iniezione di dipendenza

Quando si usa IHttpClientFactory (che si dovrebbe essere), l'integrazione dei gestori di test è semplice:

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

Poi nei vostri test:

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

Modello avanzato: Handler configurabili

Per una maggiore flessibilità, è possibile creare gestori che accettano la configurazione:

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

Uso:

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

Perché scegliere DelegatingHandler Over Mocks?

Aspect Moq-based Mocking DelegatingHandler
Linee di codice Molti Pochi
Leggibilità Basso (cermonia pesante) Alto (solo C#)
Riutilizzabilità Poor Eccellente
Debug Più duro (magia del mock) Facile (passo attraverso)
Rifattorizzazione Brindle Robusto
Curva di apprendimento Ruota (API Moq) Minimo
Dipendenze Richiede Moq Nessuno (integrato)

Quando il mocking ancora fa il senso

Ad essere onesti, ci sono scenari in cui il moq-stile potrebbe ancora essere appropriato:

  1. Risposte semplici una tantum - Se hai bisogno di un handler a risposta singola una volta, Moq in linea potrebbe essere più veloce
  2. Verifica - Moq. Verify() è utile per affermare che le chiamate sono state fatte
  3. Base di codice esistente - Se il tuo team ha già un'ampia infrastruttura Moq

Per la verifica, puoi aggiungerlo anche a DelegatingHandler:

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

Conclusione

Uso DelegatingHandler per il test HttpClient ti dà:

  • Codice compatto - Nessuna cerimonia quadro deridente
  • Prove leggibili - Solo classi normali di C#
  • Gestori riutilizzabili - Condividere le classi di test
  • Facile debug - Impostare i punti di interruzione, passare attraverso il codice
  • Dipendenze zero - E' integrato in .NET.

La prossima volta che si raggiunge per Mock<HttpMessageHandler>, considerare se un semplice DelegatingHandler Il tuo futuro te stesso (e i tuoi compagni di squadra) ti ringrazieranno per il codice di prova più pulito e manutenibile.

Vedere i progetti di test in questa soluzione per esempi reali di questo modello in azione.

logo

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