Тестування

HttpClient у Тестах Частина 2: WireMock.Net та Resilience

MockHttpMessageHandler не ловить помилки формування URL чи заголовків на рівні мережі. WireMock.Net — справжній HTTP-сервер у тестах. Навчимось перевіряти стійкість Polly retry та Circuit Breaker до збоїв.

HttpClient у Тестах Частина 2: WireMock.Net та Resilience

У попередній частині ми освоїли MockHttpMessageHandler і RichardSzalay.MockHttp. Ці інструменти чудово підходять для ізольованих юніт-тестів. Але у них є сліпа пляма.

Уявіть: ваш сервіс формує URL https://api.stripe.com/v1/charges, але через помилку у коді фактично відправляє запит на https://api.stripe.com/charges (пропущено v1/). MockHttpMessageHandler не побачить цієї різниці — ви налаштували його відповідати на wildcard *, і він відповість успіхом. Ваш тест пройде, але production впаде.

Або інший сценарій: ваш код має Polly Retry Policy — при 503 він повинен повторити запит 3 рази. Як переконатись, що retry справді спрацьовує? З MockHttpMessageHandler це можна зробити, але незручно.

WireMock.Net вирішує обидві проблеми: він запускає справжній HTTP-сервер, який слухає реальний TCP-порт. Ваш код відправляє справжні HTTP-запити — через сокет, зі справжнім DNS-резолвінгом (well, localhost), з усіма заголовками та рядком URL. WireMock перехоплює їх, перевіряє за налаштованими правилами і повертає приготовлені відповіді. Це вже не юніт-тест — це інтеграційний тест з ізольованим зовнішнім сервісом.

Знайомство з WireMock.Net

WireMock.Net — це .NET-порт популярної Java-бібліотеки WireMock. Він запускає HTTP-сервер безпосередньо всередині тестового процесу (in-process), не потребуючи зовнішніх залежностей або Docker.

dotnet add package
$ dotnet add package WireMock.Net
Successfully added WireMock.Net to MyApp.Tests.csproj

Перший тест з WireMock

using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;

public class StripeServiceWireMockTests : IDisposable
{
    private readonly WireMockServer _server;
    private readonly StripePaymentService _service;

    public StripeServiceWireMockTests()
    {
        // Запускаємо WireMock на випадковому вільному порту
        _server = WireMockServer.Start();
        
        // Створюємо HttpClient, що вказує на наш WireMock-сервер
        var client = new HttpClient
        {
            BaseAddress = new Uri(_server.Urls[0] + "/v1/")
        };
        
        _service = new StripePaymentService(client);
    }

    [Fact]
    public async Task ChargeAsync_WhenSuccess_ReturnsChargeResult()
    {
        // Arrange: налаштовуємо WireMock
        _server
            .Given(Request.Create()
                .WithPath("/v1/charges")
                .UsingPost())
            .RespondWith(Response.Create()
                .WithStatusCode(201)
                .WithHeader("Content-Type", "application/json")
                .WithBody("""{"id": "ch_live_123", "status": "succeeded", "amount": 9999}"""));
        
        // Act
        var result = await _service.ChargeAsync(99.99m, "usd", "tok_visa");
        
        // Assert
        Assert.Equal("ch_live_123", result.Id);
        Assert.Equal("succeeded", result.Status);
    }

    public void Dispose()
    {
        _server.Stop();
        _server.Dispose();
    }
}

Використання IAsyncLifetime для XUnit

У xUnit рекомендований спосіб управління ресурсами — IAsyncLifetime:

public class StripeServiceTests : IAsyncLifetime
{
    private WireMockServer _server = null!;
    private StripePaymentService _service = null!;

    public Task InitializeAsync()
    {
        // Налаштовуємо сервер до першого тесту
        _server = WireMockServer.Start(new WireMockServerSettings
        {
            Port = 0, // 0 = випадковий вільний порт
            Logger = new WireMockConsoleLogger() // для діагностики
        });
        
        var client = new HttpClient
        {
            BaseAddress = new Uri($"{_server.Url}/v1/")
        };
        
        _service = new StripePaymentService(client);
        
        return Task.CompletedTask;
    }

    public async Task DisposeAsync()
    {
        _server.Stop();
        _server.Dispose();
        await Task.CompletedTask;
    }
    
    // ... тести
}
Порт 0 = автоматичний: WireMock автоматично вибере вільний порт при значенні 0. Це дозволяє запускати декілька тестових класів паралельно без конфліктів портів.

Request Matching: Точне Визначення Запитів

Одна з головних переваг WireMock — гнучка система матчингу. Ви можете налаштовувати відповіді на основі будь-якого аспекту запиту.

Матчинг по URL та методу

// Точний URL
_server
    .Given(Request.Create()
        .WithPath("/v1/charges")
        .UsingPost())
    .RespondWith(...);

// З Path Parameters (wildcard)
_server
    .Given(Request.Create()
        .WithPath("/v1/charges/*")  // будь-який ID
        .UsingGet())
    .RespondWith(...);

// Регулярний вираз
_server
    .Given(Request.Create()
        .WithPath(new WireMock.Matchers.RegexMatcher(@"^/v1/customers/cus_\w+$"))
        .UsingGet())
    .RespondWith(...);

Матчинг по Query Parameters

_server
    .Given(Request.Create()
        .WithPath("/v1/charges")
        .UsingGet()
        .WithParam("limit", "10")
        .WithParam("customer", "cus_test123"))
    .RespondWith(Response.Create()
        .WithStatusCode(200)
        .WithBodyAsJson(new { data = new[] { new { id = "ch_123" } } }));

Матчинг по Заголовках

_server
    .Given(Request.Create()
        .WithPath("/v1/charges")
        .UsingPost()
        .WithHeader("Authorization", "Bearer sk_test_*", true) // true = wildcard
        .WithHeader("Stripe-Version", "2023-10-16")
        .WithHeader("Content-Type", "application/x-www-form-urlencoded"))
    .RespondWith(Response.Create()
        .WithStatusCode(200)
        .WithBody(chargeJson));

Якщо заголовок не збігається — WireMock поверне 404. Тест провалиться, виявивши, що заголовок відсутній або некоректний. Саме це неможливо перевірити з MockHttpMessageHandler.

Матчинг по тілу запиту

JSON Body Matching:

_server
    .Given(Request.Create()
        .WithPath("/api/orders")
        .UsingPost()
        .WithBody(new JsonMatcher(new
        {
            customerId = "cust_42",
            items = new[] { new { productId = 1, quantity = 2 } }
        })))
    .RespondWith(Response.Create()
        .WithStatusCode(201)
        .WithBodyAsJson(new { orderId = 99, status = "created" }));

JsonPath Matching — перевірка окремих полів JSON:

_server
    .Given(Request.Create()
        .WithPath("/api/orders")
        .UsingPost()
        .WithBody(new JsonPathMatcher("$.customerId"))  // поле має існувати
        .WithBody(new JsonPathMatcher("$[?(@.items.length > 0)]")))  // items непустий
    .RespondWith(...);

Form Data Matching (для Stripe-style API):

// Stripe API використовує application/x-www-form-urlencoded
_server
    .Given(Request.Create()
        .WithPath("/v1/charges")
        .UsingPost()
        .WithBody(new ExactMatcher("amount=9999&currency=usd&source=tok_visa")))
    .RespondWith(...);

Налаштування Відповідей: Templating та Затримки

Динамічні відповіді з Templating

WireMock підтримує шаблонізатор Handlebars для динамічних відповідей:

_server
    .Given(Request.Create()
        .WithPath("/v1/charges")
        .UsingPost())
    .RespondWith(Response.Create()
        .WithStatusCode(201)
        .WithHeader("Content-Type", "application/json")
        // Шаблон може використовувати дані з запиту
        .WithBody(@"{
            ""id"": ""ch_{{Random.Guid}}"",
            ""status"": ""succeeded"",
            ""created"": {{DateTime.Now.Epoch}},
            ""amount"": {{request.bodyAsFormUrlEncoded.amount}},
            ""currency"": ""{{request.bodyAsFormUrlEncoded.currency}}""
        }")
        .WithTransformer()); // Активуємо Handlebars

Симуляція Затримок (Latency)

Тестування поведінки при повільних відповідях — критично важливо для перевірки таймаутів:

// Фіксована затримка
_server
    .Given(Request.Create().WithPath("/api/slow-endpoint").UsingGet())
    .RespondWith(Response.Create()
        .WithStatusCode(200)
        .WithBody("""{"data": "finally!"}""")
        .WithDelay(TimeSpan.FromSeconds(5))); // Відповідь через 5 секунд

// Тест перевіряє, що HttpClient кидає TimeoutException:
[Fact]
public async Task GetData_WhenResponseTooSlow_ThrowsTimeoutException()
{
    _server
        .Given(Request.Create().WithPath("/api/slow-endpoint").UsingGet())
        .RespondWith(Response.Create()
            .WithDelay(TimeSpan.FromSeconds(10))
            .WithStatusCode(200)
            .WithBody("{}"));
    
    var client = new HttpClient
    {
        BaseAddress = new Uri(_server.Url),
        Timeout = TimeSpan.FromSeconds(2) // Таймаут менший за затримку
    };
    var service = new DataApiService(client);
    
    await Assert.ThrowsAsync<TaskCanceledException>(() => service.GetDataAsync());
}

// Випадкова затримка у діапазоні (для симуляції реального API)
_server
    .Given(Request.Create().WithPath("/api/realistic").UsingGet())
    .RespondWith(Response.Create()
        .WithStatusCode(200)
        .WithBody("{}")
        .WithDelay(50, 300)); // 50-300ms випадково

Fault Injection: Симуляція збоїв

Це ключова перевага WireMock над MockHttpMessageHandler — симуляція реальних мережевих збоїв.

Помилки на рівні протоколу

// Раптове закриття з'єднання (Connection Reset)
// Імітує ситуацію, коли сервер падає посеред відповіді
_server
    .Given(Request.Create().WithPath("/api/flaky").UsingGet())
    .RespondWith(Response.Create()
        .WithFault(FaultType.CONNECTION_RESET_BY_PEER));

// Порожня відповідь (без HTTP-заголовків взагалі)
_server
    .Given(Request.Create().WithPath("/api/broken").UsingGet())
    .RespondWith(Response.Create()
        .WithFault(FaultType.EMPTY_RESPONSE));

// Сміттєва відповідь (не-HTTP дані)
_server
    .Given(Request.Create().WithPath("/api/garbage").UsingGet())
    .RespondWith(Response.Create()
        .WithFault(FaultType.MALFORMED_RESPONSE_CHUNK));

Статус-коди помилок

// 500 Internal Server Error
_server
    .Given(Request.Create().WithPath("/api/broken-server").UsingGet())
    .RespondWith(Response.Create()
        .WithStatusCode(500)
        .WithHeader("Content-Type", "application/json")
        .WithBody("""{"error": "Internal Server Error", "requestId": "req_5a3f2"}"""));

// 503 Service Unavailable з Retry-After
_server
    .Given(Request.Create().WithPath("/api/overloaded").UsingGet())
    .RespondWith(Response.Create()
        .WithStatusCode(503)
        .WithHeader("Retry-After", "30")
        .WithBody("Service temporarily unavailable"));

// 429 Too Many Requests
_server
    .Given(Request.Create().WithPath("/api/rate-limited").UsingGet())
    .RespondWith(Response.Create()
        .WithStatusCode(429)
        .WithHeader("X-RateLimit-Limit", "100")
        .WithHeader("X-RateLimit-Remaining", "0")
        .WithHeader("X-RateLimit-Reset", DateTimeOffset.UtcNow.AddMinutes(1).ToUnixTimeSeconds().ToString())
        .WithBody("""{"error": "rate_limit_exceeded"}"""));

Тестування Resilience Polly: Retry та Circuit Breaker

Це найцінніший сценарій для WireMock. Інструменти на кшталт Polly додають retry-логіку, але як переконатись, що вона дійсно працює? Потрібен сервер, який відповідає по-різному на різні запити.

Налаштування Polly у Production-коді

// Program.cs
builder.Services.AddHttpClient<PaymentService>(client =>
{
    client.BaseAddress = new Uri(builder.Configuration["Stripe:BaseUrl"]!);
})
.AddPolicyHandler(HttpPolicyExtensions
    .HandleTransientHttpError() // 5xx, 408
    .Or<TimeoutRejectedException>()
    .WaitAndRetryAsync(
        retryCount: 3,
        sleepDurationProvider: attempt => TimeSpan.FromMilliseconds(Math.Pow(2, attempt) * 100),
        onRetry: (result, delay, attempt, context) =>
        {
            Log.Warning("Retry {Attempt} after {Delay}ms. Reason: {Reason}",
                attempt, delay.TotalMilliseconds, result.Exception?.Message ?? result.Result?.StatusCode.ToString());
        }))
.AddPolicyHandler(Policy.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromSeconds(10)));

Тест: Retry спрацьовує при 503

[Fact]
public async Task PayAsync_WhenStripeTemporarilyUnavailable_RetriesAndSucceeds()
{
    // Arrange
    var callCount = 0;
    
    _server
        .Given(Request.Create()
            .WithPath("/v1/charges")
            .UsingPost())
        .RespondWith(Response.Create()
            .WithCallback(requestMessage =>
            {
                callCount++;
                
                // Перші 2 запити — 503, третій — успіх
                if (callCount < 3)
                {
                    return new ResponseMessage
                    {
                        StatusCode = 503,
                        Headers = new Dictionary<string, WireMockList<string>>
                        {
                            ["Content-Type"] = new WireMockList<string>("application/json")
                        },
                        BodyData = new BodyData
                        {
                            BodyAsString = """{"error": "service_unavailable"}""",
                            DetectedBodyType = BodyType.String
                        }
                    };
                }
                
                return new ResponseMessage
                {
                    StatusCode = 200,
                    Headers = new Dictionary<string, WireMockList<string>>
                    {
                        ["Content-Type"] = new WireMockList<string>("application/json")
                    },
                    BodyData = new BodyData
                    {
                        BodyAsString = """{"id": "ch_ok", "status": "succeeded"}""",
                        DetectedBodyType = BodyType.String
                    }
                };
            }));
    
    // Act
    var result = await _service.ChargeAsync(99m, "usd", "tok_visa");
    
    // Assert
    Assert.Equal("succeeded", result.Status);
    Assert.Equal(3, callCount); // Polly зробив 3 спроби
}

Більш елегантний підхід: Список відповідей

WireMock підтримує response sequences — список відповідей, які повертаються по черзі:

[Fact]
public async Task PayAsync_RetrySequence_FirstTwoFail_ThirdSucceeds()
{
    // Arrange: налаштовуємо послідовність відповідей
    _server
        .Given(Request.Create()
            .WithPath("/v1/charges")
            .UsingPost())
        // InScenario дозволяє визначати стани
        .InScenario("Retry Scenario")
        .WillSetStateTo("Second Attempt")
        .RespondWith(Response.Create()
            .WithStatusCode(503)
            .WithBody("""{"error": "service_unavailable"}"""));

    _server
        .Given(Request.Create()
            .WithPath("/v1/charges")
            .UsingPost())
        .InScenario("Retry Scenario")
        .WhenStateIs("Second Attempt")
        .WillSetStateTo("Third Attempt")
        .RespondWith(Response.Create()
            .WithStatusCode(503)
            .WithBody("""{"error": "service_unavailable"}"""));

    _server
        .Given(Request.Create()
            .WithPath("/v1/charges")
            .UsingPost())
        .InScenario("Retry Scenario")
        .WhenStateIs("Third Attempt")
        .RespondWith(Response.Create()
            .WithStatusCode(200)
            .WithHeader("Content-Type", "application/json")
            .WithBody("""{"id": "ch_final", "status": "succeeded"}"""));

    // Act
    var result = await _service.ChargeAsync(99m, "usd", "tok_visa");
    
    // Assert
    Assert.Equal("ch_final", result.Id);
    
    // Перевіряємо, що WireMock отримав рівно 3 запити
    var requests = _server.LogEntries.ToList();
    Assert.Equal(3, requests.Count);
}

Тест: Circuit Breaker відкривається після N fails

Circuit Breaker — патерн, який після певної кількості послідовних помилок «відкриває ланцюг» і на певний час перестає намагатись зробити запит, відразу кидаючи виняток. Це захищає від каскадних збоїв.

// Налаштування Circuit Breaker у Program.cs
.AddPolicyHandler(Policy<HttpResponseMessage>
    .Handle<HttpRequestException>()
    .OrResult(r => (int)r.StatusCode >= 500)
    .CircuitBreakerAsync(
        handledEventsAllowedBeforeBreaking: 3, // відкривається після 3 збоїв
        durationOfBreak: TimeSpan.FromSeconds(30))); // закритий 30 секунд

// Тест Circuit Breaker:
[Fact]
public async Task CircuitBreaker_AfterThreeFailures_OpenAndThrows()
{
    // Arrange: сервер завжди відповідає 500
    _server
        .Given(Request.Create().WithPath("/v1/charges").UsingPost())
        .RespondWith(Response.Create()
            .WithStatusCode(500)
            .WithBody("""{"error": "server_error"}"""));
    
    // Act & Assert
    // Перші 3 запити — реальні запити до WireMock (отримують 500)
    for (int i = 0; i < 3; i++)
    {
        await Assert.ThrowsAsync<HttpRequestException>(() =>
            _service.ChargeAsync(99m, "usd", "tok_visa"));
    }
    
    // 4-й запит — Circuit Breaker вже відкритий, запит до WireMock не йде
    await Assert.ThrowsAsync<BrokenCircuitException>(() =>
        _service.ChargeAsync(99m, "usd", "tok_visa"));
    
    // Перевіряємо: WireMock отримав рівно 3 запити (4-й був заблокований)
    Assert.Equal(3, _server.LogEntries.Count());
}
Тест Circuit Breaker без WireMock практично неможливо написати чисто. MockHttpMessageHandler можна змусити кидати виняток, але перевірити, що четвертий запит ВЗАГАЛІ не пішов у мережу, неможливо без реального сервера, який рахує вхідні запити.

Верифікація Запитів у WireMock

WireMock веде лог всіх запитів, що він отримав. Це дозволяє робити assertions не лише на відповідь, але й на те, скільки разів і з якими параметрами ваш код робив запити.

[Fact]
public async Task ChargeAsync_AlwaysSendsStripeVersionHeader()
{
    // Arrange
    _server
        .Given(Request.Create().WithPath("/v1/charges").UsingPost())
        .RespondWith(Response.Create()
            .WithStatusCode(200)
            .WithBody("""{"id": "ch_123", "status": "succeeded"}"""));
    
    // Act
    await _service.ChargeAsync(99m, "usd", "tok_visa");
    
    // Assert: перевіряємо заголовок у реально отриманому запиті
    var logEntry = _server.LogEntries.Single();
    
    Assert.True(
        logEntry.RequestMessage.Headers.ContainsKey("Stripe-Version"),
        "Stripe-Version header missing");
    
    Assert.Equal("2023-10-16",
        logEntry.RequestMessage.Headers["Stripe-Version"].First());
    
    // Перевіряємо URL
    Assert.Equal("/v1/charges", logEntry.RequestMessage.Path);
    
    // Перевіряємо метод
    Assert.Equal("POST", logEntry.RequestMessage.Method);
    
    // Перевіряємо деталі тіла
    Assert.Contains("amount=9999", logEntry.RequestMessage.Body);
}

[Fact]
public async Task GetChargesAsync_UsesPaginationParameters()
{
    _server
        .Given(Request.Create().WithPath("/v1/charges").UsingGet())
        .RespondWith(Response.Create()
            .WithStatusCode(200)
            .WithBody("""{"data": [], "has_more": false}"""));
    
    await _service.GetChargesAsync(limit: 25, startingAfter: "ch_previous");
    
    var request = _server.LogEntries.Single().RequestMessage;
    
    // Перевіряємо query параметри
    Assert.Equal("25", request.Query["limit"].First());
    Assert.Equal("ch_previous", request.Query["starting_after"].First());
}

Інтеграційні Тести з WebApplicationFactory + WireMock

Найпотужніший сценарій — поєднати WebApplicationFactory (для тестування всього ASP.NET пайплайну) з WireMock (для ізоляції зовнішніх HTTP-залежностей):

public class OrdersEndpointTests : IClassFixture<WireMockWebApplicationFactory>
{
    private readonly WireMockWebApplicationFactory _factory;
    
    public OrdersEndpointTests(WireMockWebApplicationFactory factory)
    {
        _factory = factory;
    }

    [Fact]
    public async Task PostOrder_WhenPaymentSucceeds_Returns201()
    {
        // Arrange: налаштовуємо WireMock для Stripe
        _factory.PaymentServer
            .Given(Request.Create()
                .WithPath("/v1/charges")
                .UsingPost())
            .RespondWith(Response.Create()
                .WithStatusCode(200)
                .WithBody("""{"id": "ch_test", "status": "succeeded"}"""));
        
        var client = _factory.CreateClient();
        
        // Act: відправляємо запит до нашого ASP.NET API
        var response = await client.PostAsJsonAsync("/api/orders", new
        {
            customerId = "cust_1",
            items = new[] { new { productId = 1, quantity = 2 } },
            paymentSource = "tok_visa"
        });
        
        // Assert: наш API повернув 201
        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        
        // WireMock отримав запит до Stripe
        Assert.Single(_factory.PaymentServer.LogEntries);
    }

    [Fact]
    public async Task PostOrder_WhenPaymentFails_Returns402()
    {
        // Arrange: Stripe відхиляє картку
        _factory.PaymentServer
            .Given(Request.Create().WithPath("/v1/charges").UsingPost())
            .RespondWith(Response.Create()
                .WithStatusCode(402)
                .WithBody("""{"error": {"type": "card_error", "code": "card_declined"}}"""));
        
        var client = _factory.CreateClient();
        
        var response = await client.PostAsJsonAsync("/api/orders", new
        {
            customerId = "cust_1",
            items = new[] { new { productId = 1, quantity = 2 } },
            paymentSource = "tok_chargeDeclined"
        });
        
        Assert.Equal(HttpStatusCode.PaymentRequired, response.StatusCode);
    }
}

// Кастомна фабрика з WireMock:
public class WireMockWebApplicationFactory : WebApplicationFactory<Program>
{
    public WireMockServer PaymentServer { get; }
    public WireMockServer EmailServer { get; }

    public WireMockWebApplicationFactory()
    {
        // Запускаємо WireMock-сервери для кожної зовнішньої залежності
        PaymentServer = WireMockServer.Start();
        EmailServer = WireMockServer.Start();
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureAppConfiguration((context, config) =>
        {
            // Підміняємо URL зовнішніх сервісів на наші WireMock-адреси
            config.AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["Stripe:BaseUrl"] = PaymentServer.Url,
                ["SendGrid:BaseUrl"] = EmailServer.Url
            });
        });
    }

    protected override void Dispose(bool disposing)
    {
        PaymentServer.Stop();
        PaymentServer.Dispose();
        EmailServer.Stop();
        EmailServer.Dispose();
        base.Dispose(disposing);
    }
}
Очищення між тестами: Якщо клас використовується через IClassFixture (WireMock запущений один раз для всіх тестів), не забувайте очищати логи та налаштування між тестами: _factory.PaymentServer.ResetMappings(); _factory.PaymentServer.ResetLogEntries(); — наприклад, у IAsyncLifetime.InitializeAsync().

Mock vs WireMock: Коли що використовувати

MockHttpMessageHandler

Використовуйте для:

  • Швидких юніт-тестів окремого сервісу
  • Перевірки обробки різних HTTP-статусів
  • Тестів без залежності від мережевого стека
  • Проєктів без складних resilience-вимог

Переваги: Швидко, просто, без зайвих залежностей

WireMock.Net

Використовуйте для:

  • Тестування Polly Retry / Circuit Breaker
  • Перевірки точних URL, заголовків, тіла запитів
  • Симуляції мережевих збоїв та затримок
  • Інтеграційних тестів з WebApplicationFactory
  • Верифікації кількості HTTP-викликів

Переваги: Реальний мережевий стек, детальний лог


Практика


Ми освоїли весь арсенал інструментів для тестування HTTP у .NET: від MockHttpMessageHandler до реального WireMock-сервера. У наступній статті — перейдемо від інструментів до мистецтва написання тестів. Поговоримо про Test Smells, патерни та про те, як зробити тестовий код таким же охайним, як production.