استراتژیهای جامع و کاربردی تست واحد HttpClient در NET.
نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۵/۰۹ ۱۰:۰۰
آدرس: www.dntips.ir
چکیده: تستنویسی واحد (Unit Testing) برای سرویسهایی که با فراخوانیهای شبکه و پروتکل HTTP سر و کار دارند، همواره یکی از چالشهای اصلی توسعهدهندگان داتنت بوده است. تلاش برای ساخت همزاد شبیهسازیشده (Mock) از کلاسHttpClientبه دلیل عدم وجود اینترفیس مستقیم و مجازی (Virtual) نبودن متدهای متداول آن، منجر به خطاهای زمان اجرا یا طراحیهای پیچیده و ناکارآمد میشود. این مقاله با شکافتن معماری داخلیHttpClientنشان میدهد که کلید حل این مسئله، شبیهسازی لایه زیرین یعنیHttpMessageHandlerاست. در ادامه، ۶ رویکرد عملی و تستشده — از پیادهسازی بدون وابستگی خارجی تا استفاده از کتابخانههای پیشرفته و آزمونهای یکپارچهسازی (Integration Testing) — همراه با کد نمونه روان، بررسی مقایسهای و تحلیل کارایی ارائه شده است.
HttpClient در داتنت ابزار اصلی برای ارسال درخواستهای HTTP و دریافت پاسخهاست. با این حال، هنگام نوشتن تستهای واحد برای کلاسهای ارائهدهنده خدمات (Service Layer)، توسعهدهندگان اغلب با بنبست مواجه میشوند.HttpClient به عنوان وابستگی (Dependency) به سرویس تزریق میشود، شبیهسازی مستقیم آن با ابزارهای رایج مانند Moq یا NSubstitute ناممکن است. دلیل فنی آن روشن است: HttpClient از یک اینترفیس اصلی مانند IHttpClient بهره نمیبرد و متدهای کلیدی آن نظیر GetAsync و PostAsync مجازی (Virtual) نیستند.IHttpClientWrapper نیز در اکثر موارد پیچیدگی معماری را بدون دلیل افزایش میدهد.HttpClient را در چارچوب .NET درک کنیم. بر خلاف تصور عموم، کلاس HttpClient خود عمل ارسال و دریافت اطلاعات روی شبکه را انجام نمیدهد؛ بلکه این کلاس صرفاً یک پوسته و رابط کاربری سطح بالا (High-Level Wrapper) روی خط لوله پردازش درخواستهاست.[ Your Service ] ---> [ HttpClient ] ---> [ HttpMessageHandler ] ---> [ Internet ]
↑
(ما این بخش را شبیهسازی میکنیم)HttpClient تمامی درخواستها را به کلاسی انتزاعی به نام HttpMessageHandler میسپارد. سازنده کلاس HttpClient پارامتری از جنس HttpMessageHandler میپذیرد. تنها کافی است یک نمونه دستکاریشده (Mock/Fake) از این هندر را به سازنده تزریق کنیم. تمام متدهای متداول (مانند GetFromJsonAsync, PostAsync, SendAsync) در نهایت متد متمرکز زیر را در HttpMessageHandler فراخوانی میکنند:protected abstract Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken);HttpMessageHandler بنویسید:public class MockHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _sendAsync;
public MockHttpMessageHandler(HttpResponseMessage response)
{
_sendAsync = (_, _) => Task.FromResult(response);
}
public MockHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> sendAsync)
{
_sendAsync = sendAsync;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return _sendAsync(request, cancellationToken);
}
}[Fact]
public async Task FetchUserAsync_ReturnsDeserializedUser_WhenResponseIs200OK()
{
// Arrange
var expectedUser = new User { Id = 42, Name = "آدا لولاس" };
var jsonPayload = JsonSerializer.Serialize(expectedUser);
var handler = new MockHttpMessageHandler(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json")
});
var client = new HttpClient(handler) { BaseAddress = new Uri("https://api.example.com") };
var userService = new UserService(client);
// Act
var actualUser = await userService.FetchUserAsync(42);
// Assert
Assert.NotNull(actualUser);
Assert.Equal(expectedUser.Name, actualUser.Name);
}dotnet add package RichardSzalay.MockHttp
[Fact]
public async Task SearchProductsAsync_ReturnsMatchingItems()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
mockHttp.When(HttpMethod.Get, "https://api.shop.com/products?search=*")
.Respond("application/json", """
[
{ "id": 1, "name": "کیبورد مکانیکی" },
{ "id": 2, "name": "ماوس بیسیم" }
]
""");
var client = mockHttp.ToHttpClient();
client.BaseAddress = new Uri("https://api.shop.com");
var productService = new ProductService(client);
// Act
var products = await productService.SearchAsync("mech");
// Assert
Assert.Equal(2, products.Count);
}SendAsync یک متد protected است، باید از API ویژه Protected() در Moq استفاده کنید:[Fact]
public async Task DeleteObjectAsync_SendsCorrectQueryString()
{
// Arrange
var handlerMock = new Mock<HttpMessageHandler>(MockBehavior.Strict);
handlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(req =>
req.RequestUri!.Query.Contains("name=my-file")),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.NoContent))
.Verifiable();
var httpClient = new HttpClient(handlerMock.Object)
{
BaseAddress = new Uri("https://storage.example.com")
};
var factoryMock = new Mock<IHttpClientFactory>();
factoryMock.Setup(x => x.CreateClient("storage")).Returns(httpClient);
var storageService = new StorageService(factoryMock.Object);
// Act
await storageService.DeleteObjectAsync("my-file");
// Assert
handlerMock.Protected().Verify(
"SendAsync",
Times.Exactly(1),
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>());
}Protected() در Moq کمی سنگین است. کتابخانه Moq.Contrib.HttpClient متدهای توسعهای (Extension Methods) مفیدی ارائه میدهد که شبیهسازی را بسیار سادهتر میکند:// dotnet add package Moq.Contrib.HttpClient
var handler = new Mock<HttpMessageHandler>();
handler.SetupRequest(HttpMethod.Get, "https://api.example.com/users/1")
.ReturnsJsonResponse(new User { Id = 1, Name = "گریس هاپر" });
var client = handler.CreateClient();
var factory = handler.CreateClientFactory();IHttpClientFactory یا کلاینتهای تایپشده (Typed Clients) استفاده میشود.HttpClient را در سازنده دریافت کرده و با AddHttpClient ثبت میشوند) استفاده میکنید، نیازی به شبیهسازی IHttpClientFactory ندارید! کافی است نمونه کلاینت تایپشده را مستقیماً با یک HttpClient شبیهسازیشده بسازید:// تست کلاینت تایپ شده
var mockHttp = new MockHttpMessageHandler();
mockHttp.When("*").RespondJson(new Receipt { TransactionId = "txn_123", Amount = 99.99m });
var client = mockHttp.ToHttpClient();
client.BaseAddress = new Uri("https://payments.example.com");
// ساخت مستقیم Typed Client بدون نیاز به Mock کردن Factory
var paymentService = new PaymentService(client);
var receipt = await paymentService.ChargeAsync(new ChargeRequest { Amount = 99.99m });
Assert.Equal("txn_123", receipt.TransactionId);WireMock.NET یک HTTP Server واقعی روی یک پورت تصادفی اجرا میکند:// dotnet add package WireMock.Net
public class PaymentApiIntegrationTests : IDisposable
{
private readonly WireMockServer _server;
private readonly HttpClient _client;
public PaymentApiIntegrationTests()
{
_server = WireMockServer.Start();
_client = new HttpClient { BaseAddress = new Uri(_server.Url!) };
}
[Fact]
public async Task GetPayment_Returns200_WhenServerIsResponsive()
{
_server
.Given(Request.Create().WithPath("/payments/123").UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithBodyAsJson(new { id = "123", status = "paid" }));
var service = new PaymentService(_client);
var result = await service.GetPaymentAsync("123");
Assert.Equal("paid", result.Status);
}
public void Dispose() => _server?.Dispose();
}HttpMessageHandler زیرین را شبیهسازی کنید.public static class HttpClientTestFactory
{
public static HttpClient CreateClient(
HttpStatusCode statusCode = HttpStatusCode.OK,
object? responseBody = null,
Action<HttpRequestMessage>? requestAssertion = null)
{
var handler = new MockHttpMessageHandler(async (req, ct) =>
{
requestAssertion?.Invoke(req);
var response = new HttpResponseMessage(statusCode);
if (responseBody is not null)
{
var json = JsonSerializer.Serialize(responseBody, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
response.Content = new StringContent(json, Encoding.UTF8, "application/json");
}
return await Task.FromResult(response);
});
return new HttpClient(handler);
}
public static IHttpClientFactory CreateFactory(
string clientName,
HttpStatusCode statusCode = HttpStatusCode.OK,
object? responseBody = null)
{
var client = CreateClient(statusCode, responseBody);
var factoryMock = new Mock<IHttpClientFactory>();
factoryMock.Setup(f => f.CreateClient(clientName)).Returns(client);
return factoryMock.Object;
}
}| رویکرد / ابزار | نوع تست | وابستگی خارجی | میزان سادگی کد | سناریوی پیشنهادی |
| Custom Handler | Unit Test | ندارد (Pure C#) | متوسط | پروژههای بدون پکیج جانبی یا ساخت کتابخانه پایه |
| RichardSzalay.MockHttp | Unit Test | پکیج MockHttp | عالی | تستهای واحد روزمره و سناریوهای استاندارد API |
| Moq + Protected() | Unit Test | پکیج Moq | پیچیده | پروژههایی که استاندارد آنها فقط Moq است |
| Moq.Contrib.HttpClient | Unit Test | Moq + Contrib | خوب | تسهیل کار با Moq در تستهای واحد |
| WireMock.NET | Integration Test | پکیج WireMock.Net | عالی | تست پایداری (Polly)، Timeoutها و لایههای کامل شبکه |
HttpClient و در عوض هدایت خط لوله از طریق HttpMessageHandler است. با بهکارگیری ۶ استراتژی مطرحشده در این مقاله، میتوان بستهبه سطح تست (Unit یا Integration) و الزامات پروژه، مناسبترین ابزار را انتخاب کرد و برنامههایی با قابلیت نگهداری بالا، تستپذیر و عاری از باگهای غیرمنتظره شبکه توسعه داد.