عنوان:

‫استراتژی‌های جامع و کاربردی تست واحد HttpClient در NET.


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۵/۰۹ ۱۰:۰۰
آدرس: www.dntips.ir
چکیده: تست‌نویسی واحد (Unit Testing) برای سرویس‌هایی که با فراخوانی‌های شبکه و پروتکل HTTP سر و کار دارند، همواره یکی از چالش‌های اصلی توسعه‌دهندگان دات‌نت بوده است. تلاش برای ساخت همزاد شبیه‌سازی‌شده (Mock) از کلاس HttpClient به دلیل عدم وجود اینترفیس مستقیم و مجازی (Virtual) نبودن متدهای متداول آن، منجر به خطاهای زمان اجرا یا طراحی‌های پیچیده و ناکارآمد می‌شود. این مقاله با شکافتن معماری داخلی HttpClient نشان می‌دهد که کلید حل این مسئله، شبیه‌سازی لایه زیرین یعنی HttpMessageHandler است. در ادامه، ۶ رویکرد عملی و تست‌شده — از پیاده‌سازی بدون وابستگی خارجی تا استفاده از کتابخانه‌های پیشرفته و آزمون‌های یکپارچه‌سازی (Integration Testing) — همراه با کد نمونه روان، بررسی مقایسه‌ای و تحلیل کارایی ارائه شده است.

۱. مقدمه و تبیین مسئله
در توسعه نرم‌افزارهای مدرن، معماری مبتنی بر میکروخدمات و ارتباطات وب‌سرویسی (REST APIs) سهم عمده‌ای از برنامه‌نویسی را به خود اختصاص داده است. کلاس HttpClient در دات‌نت ابزار اصلی برای ارسال درخواست‌های HTTP و دریافت پاسخ‌هاست. با این حال، هنگام نوشتن تست‌های واحد برای کلاس‌های ارائه‌دهنده خدمات (Service Layer)، توسعه‌دهندگان اغلب با بن‌بست مواجه می‌شوند.
هنگامی که یک HttpClient به عنوان وابستگی (Dependency) به سرویس تزریق می‌شود، شبیه‌سازی مستقیم آن با ابزارهای رایج مانند Moq یا NSubstitute ناممکن است. دلیل فنی آن روشن است: HttpClient از یک اینترفیس اصلی مانند IHttpClient بهره نمی‌برد و متدهای کلیدی آن نظیر GetAsync و PostAsync مجازی (Virtual) نیستند.
از طرفی، ایجاد یک نمونه واقعی (Instantiation) در محیط تست، باعث ارسال درخواست واقعی روی شبکه‌ی اینترنت، کندی آزمون‌ها و عدم قطعیت در نتایج (Flakiness) می‌شود. ایجاد یک لایه انتزاعی اضافی (Wrapper) مانند IHttpClientWrapper نیز در اکثر موارد پیچیدگی معماری را بدون دلیل افزایش می‌دهد.

۲. معماری داخلی HttpClient و راز HttpMessageHandler
برای حل ریشه‌ای این مسئله، باید ساختار داخلی 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);
بنابراین، پیاده‌سازی و کنترل همین یک متد متمرکز، مدیریت کامل رفتار HTTP برنامه در محیط تست را بدون نیاز به شبکه فراهم می‌سازد.

۳. رویکردها و استراتژی‌های شبیه‌سازی (Mocking Strategies)
رویکرد اول: ساخت Mock Handler اختصاصی (روش بدون وابستگی / Zero-Dependency)
اگر مایل به اضافه کردن کتابخانه‌های جانبی (NuGet) نیستید یا قصد دارید یک کتابخانه تست سبک و قابل اشتراک‌گذاری در پروژه ایجاد کنید، می‌توانید کلاس سفارشی خود را بر پایه 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);
}

رویکرد دوم: استفاده از کتابخانه RichardSzalay.MockHttp (ابزار استاندارد و تخصصی)
این کتابخانه بدون وابستگی به فریم‌ورک‌های موک عمومی، نحوه تعریف انتظارات HTTP را بسیار خوانا و شبیه به زبان طبیعی می‌کند. پشتیبانی از الگوی آدرس‌دهی با Wildcard و تطبیق درخواست‌ها از ویژگی‌های برجسته آن است:
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);
}

رویکرد سوم: شبیه‌سازی مستقیم با Moq و متد Protected (روش تیم‌های متکی بر Moq)
اگر در پروژه خود از فریم‌ورک Moq استفاده می‌کنید، نیازی به نصب پکیج جدید ندارید. با این حال، چون 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>());
}

رویکرد چهارم: تسهیل Moq با Moq.Contrib.HttpClient
سینتکس 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
در برنامه‌های واقعی دات‌نت، معمولاً از 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
در تست‌های یکپارچه‌سازی (Integration Tests)، شبیه‌سازی هندر کافی نیست؛ زیرا نیاز به بررسی رفتارهای واقعی شبکه مانند سیاست‌های بازتلاش (Retry Policies با Polly)، زمان انقضا (Timeouts)، قطعی سرور (HTTP 500) یا خطاهای SSL داریم. 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();
}

۴. قوانین طلایی و کلاس کمکی قابل استفاده مجدد (Helper Utility)
قوانین طلایی در تست‌نویسی HttpClient:
  • هرگز خود HttpClient را موک نکنید: همیشه HttpMessageHandler زیرین را شبیه‌سازی کنید.
  • تنها به وضعیت (Status Code) اکتفا نکنید: حتماً Headerها، URL و Body درخواست‌های ارسالی را Assert کنید.
  • از Strict Mocking استفاده کنید: برای جلوگیری از فرار درخواست‌های غیرمنتظره، حالت Strict توصیه می‌شود.
  • برای تست‌های Resilience از WireMock بهره ببرید: رفتارهای پیچیده شبکه مانند Polly Retry و Timeout با Mock Handler قابل تست دقیق نیستند.

برای جلوگیری از تکرار کد (Boilerplate Code) در پروژه، می‌توانید از کلاس کمکی زیر بهره ببرید:
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 HandlerUnit Testندارد (Pure C#)متوسطپروژه‌های بدون پکیج جانبی یا ساخت کتابخانه پایه
RichardSzalay.MockHttpUnit Testپکیج MockHttpعالیتست‌های واحد روزمره و سناریوهای استاندارد API
Moq + Protected()Unit Testپکیج Moqپیچیدهپروژه‌هایی که استاندارد آن‌ها فقط Moq است
Moq.Contrib.HttpClientUnit TestMoq + Contribخوبتسهیل کار با Moq در تست‌های واحد
WireMock.NETIntegration Testپکیج WireMock.Netعالیتست پایداری (Polly)، Timeoutها و لایه‌های کامل شبکه

۶. نتیجه‌گیری
تست‌نویسی برای ارتباطات HTTP در دات‌نت برخلاف تصور اولیه، پیچیده نیست؛ به شرط آنکه مرزهای معماری فریم‌ورک را بشناسیم. کلید اصلی، دست کشیدن از تلاش برای شبیه‌سازی کلاس HttpClient و در عوض هدایت خط لوله از طریق HttpMessageHandler است. با به‌کارگیری ۶ استراتژی مطرح‌شده در این مقاله، می‌توان بسته‌به سطح تست (Unit یا Integration) و الزامات پروژه، مناسب‌ترین ابزار را انتخاب کرد و برنامه‌هایی با قابلیت نگهداری بالا، تست‌پذیر و عاری از باگ‌های غیرمنتظره شبکه توسعه داد.