Back to "Évaluation de l'unité HttpClient sans moquettes"

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

Évaluation de l'unité HttpClient sans moquettes

Saturday, 29 November 2025

Introduction

Lorsque vous testez un code qui utilise HttpClient, l’approche traditionnelle implique le ridicule HttpMessageHandler utilisation de cadres comme Moq. Alors que cela fonctionne, il peut être verboseM SK2 la cérémonieMSC3 lourde , et, franchement, un peu grèveMST5 Il existeMSL6 une alternative moins polluante MST7 utilisant DelegatingHandler pour créer des gestionnaires d'essai qui se comportent comme des points de fin HTTP réels.

Dans ce post I' je vous montrerai pourquoi vous pourriez passer les modèles complètement et utiliser DelegatingHandler pour un code d'essai plus lisible, entretenable, et compact

Le problème avec Mocking HttpMessageHandler

Voici ce qui est typique HttpMessageHandler mocking ressemble à 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);

Cela pose plusieurs problèmes:

  1. Verbose - De nombreuses plaques pour ce qui devrait être un comportement simple
  2. Cérémonie de la méthode protégée - Vous avez besoin Protected() et ItExpr car SendAsync est protégée
  3. Difficile à lire - La logique d'essai réelle est enterrée dans la cérémonie de mise en place
  4. Non réutilisable - Chaque essai a besoin d’un code de configuration similaire
  5. Britle - Il est facile de déduire incorrectement le nom de la méthode basée sur la chaîne

La solution de rechange du gestionnaire délégué

DelegatingHandler est une classe NET construite- dans . conçue exactement à cette fin - interceptant les demandes HTTP avant qu'elles ne pénètrent sur le réseau

Voici' la même fonctionnalité en utilisant 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")
        };
    }
}

Utilisation:

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

Il n’y a pas de cadres à reprocher; il n’existe aucune méthode de gymnastique protégée; on ne dispose d’aucune chaîne; les méthodologies sont fondées sur des noms.

Un agent de services de traduction réel-Exemple mondiale:

Voici un exemple plus sophistiqué d’un gestionnaire de tests du service de traduction.

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

Ce gestionnaire:

  • Router les différentes voies vers des comportements différents
  • Déserialiser le contenu de la demande pour prendre des décisions
  • Retourne les codes d'erreur appropriés pour des scénarios spécifiques
  • est entièrement lisible et auto-documentant

Établissement d'une injection de dépendance

Au moment de l'utilisation IHttpClientFactory (que vous devriez êtreM SK1 l'intégration des gestionnaires d'essai est simple :

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

Ensuite, dans vos tests:

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

Modèle avancé: Manipulateurs configurables

Pour plus de souplesse, vous pouvez créer des gestionnaires qui acceptent la configuration:

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

Usage:

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

Pourquoi choisir DelegatingHandler plutôt que Mocks?

Aspect Moq-Mocking basé sur le mockery Manufacteur délégué M
Lignes de code Bon nombre Moins
Lectabilité Faible (Cérémonie lourdeM SK2
Réutilisation Faible Excellent
Débugging Plus dur ( magique de fumer MSC2 MSC3 facile SMC4 marcher par l'entremise SMC5 SMC6
Réfactorisation Brittle Robuste
Courbe d'apprentissage Steeper (Moq APIs) MSC3 Minimal SMC4
Dépendances Besoins de Moq Aucune

Lorsqu'il y a toujours raison de faire des moquettes

Pour être juste, il y a des scénarios où le moquage de style Moq - pourrait encore s’avérer approprié

  1. Un-pour les réponses simples - Si vous avez besoin d'un seul manipulateur de réponses, une seule fois, - Moq en ligne peut être plus rapide
  2. Vérification - MoqM SK1s Verify() est utile pour affirmer que des appels ont été faits
  3. Base de code existante - Si votre équipe possède déjà une vaste infrastructure Moq

Pour la vérification, vous pouvez l’ajouter à DelegatingHandler aussi:

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

Conclusion

Utilisation DelegatingHandler pour le test HttpClient vous donne:

  • Code compact - Aucune cérémonie de remise en forme
  • Essais lisibles - Justes classes régulières CM SK1
  • Traiteurs réutilisables - Partage entre les classes d’examen
  • Débugging facile - Établir les points de rupture, passer par le code
  • Nulle dépendance - Il' est intégré dans .NET

La prochaine fois que vous atteignez Mock<HttpMessageHandler>, examiner si un simple DelegatingHandler Il vous servirait mieux. Votre avenir personnel M SK1et vos collègues de l’équipe ) vous remercieront pour le code d’essai plus propre, plus facile à entretenir

Voir les projets d’essai dans cette solution pour des exemples réels de ce modèle en action.

logo

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