Unit Testing HttpClient OHNE Mocks
Einleitung
Beim Testen von Code, der verwendet wird HttpClient, der traditionelle Ansatz beinhaltet Spott HttpMessageHandler mit Frameworks wie Moq. Während dies funktioniert, kann es verbal sein, Zeremonie-schwer, und offen gesagt ein bisschen hässlich. Es gibt eine saubere Alternative: mit DelegatingHandler um Testhandler zu erstellen, die sich wie echte HTTP-Endpunkte verhalten.
In diesem Beitrag werde ich Ihnen zeigen, warum Sie die Spott ganz überspringen und verwenden DelegatingHandler für lesbareren, haltbareren und kompakteren Testcode.
Das Problem mit Mocking HttpMessageHandler
Hier ist, was typisch ist HttpMessageHandler Spott sieht aus wie mit 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);
Dies hat mehrere Probleme:
- Verbiegung - Viele Kesselplatten für ein einfaches Verhalten
- Geschützte Methode Zeremonie - Du brauchst
Protected()undItExprweilSendAsyncgeschützt ist - Schwer zu lesen - Die eigentliche Testlogik ist in der Setup-Zeremonie begraben
- Nicht wiederverwendbar - Jeder Test benötigt einen ähnlichen Setup-Code
- Bruch - Einfach den String-basierten Methodennamen falsch zu bekommen
Die delegierende Handler-Alternative
DelegatingHandler ist eine eingebaute .NET-Klasse, die genau für diesen Zweck entwickelt wurde - das Abfangen von HTTP-Anfragen, bevor sie das Netzwerk treffen. Es ist, was Middleware wie retry handlers, Protokoll-Handler und Authentifizierung handlers in der Produktion verwenden.
Hier ist die gleiche Funktionalität mit 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")
};
}
}
Verwendung:
var client = new HttpClient(new EchoHandler());
Keine spöttischen Frameworks, keine geschützten Methodenturnen, keine String-basierten Methodennamen.
Ein Beispiel für die reale Welt: Übersetzungsdienst Handler
Hier ist ein anspruchsvolleres Beispiel aus einem Translation Service Test Handler:
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)
};
}
}
Dieser Handler:
- Wege zu unterschiedlichen Verhaltensweisen
- Deserialisiert Anfragen Inhalt zu Entscheidungen zu treffen
- Gibt entsprechende Fehlercodes für spezifische Szenarien zurück
- Ist vollständig lesbar und selbstdokumentiert
Einrichten mit Abhängigkeitsinjektion
Bei der Anwendung IHttpClientFactory (was Sie sein sollten) ist die Integration von Testhandlern einfach:
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;
}
Dann in Ihren 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"));
}
Erweitertes Muster: Konfigurierbare Handler
Für mehr Flexibilität können Sie Handler erstellen, die die Konfiguration akzeptieren:
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);
}
}
Verwendung:
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);
Warum wählen Sie DelegatingHandler über Mocks?
Benachteiligtes Moq-basiertes Mocking, Delegieren von Handler |--------|-------------------|-------------------| | Codezeilen Viele wenige | Lesbarkeit Tief (Zeremonie schwer) Hoch (nur C#) | Wiederverwendbarkeit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . | Debugging Härter (Mockmagie) Einfach (Schritt durch) | Refaktorisierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . | Lernkurve Steeper (Moq-APIs) | Abhängigkeiten Benötigt keine (eingebauten)
Wenn Mocking immer noch Sinn macht
Um fair zu sein, gibt es Szenarien, in denen Moq-Stil Spott noch angemessen sein könnte:
- Einmalige einfache Antworten - Wenn Sie einmal einen Single-Response-Handler benötigen, könnte Inline Moq schneller sein
- Überprüfung - Moq's
Verify()ist nützlich für die Behauptung, Anrufe wurden gemacht - Bestehende Codebasis - Wenn Ihr Team bereits über umfangreiche Moq-Infrastruktur
Zur Überprüfung können Sie es auch dem DelegatingHandler hinzufügen:
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));
}
}
Schlußfolgerung
Verwendung DelegatingHandler für HttpClient-Tests gibt Ihnen:
- Kompakter Code - Keine Verspottung Rahmen Zeremonie
- Lesbare Tests - Nur normale C#-Kurse
- Wiederverwendbare Handler - Anteil über die Testklassen
- Einfaches Debuggen - Haltepunkte setzen, Code durchtreten
- Null-Abhängigkeiten - Es ist in .NET eingebaut.
Das nächste Mal, wenn Sie nach Mock<HttpMessageHandler>, prüfen, ob eine einfache DelegatingHandler Ihr zukünftiges Selbst (und Ihre Teamkollegen) wird Ihnen für den saubereren, wartungsfähigeren Testcode danken.
Siehe die Testprojekte in dieser Lösung für reale Beispiele dieses Musters in Aktion.