У попередній частині ми освоїли 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 — це .NET-порт популярної Java-бібліотеки WireMock. Він запускає HTTP-сервер безпосередньо всередині тестового процесу (in-process), не потребуючи зовнішніх залежностей або Docker.
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();
}
}
У 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 — гнучка система матчингу. Ви можете налаштовувати відповіді на основі будь-якого аспекту запиту.
// Точний 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(...);
_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¤cy=usd&source=tok_visa")))
.RespondWith(...);
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
Тестування поведінки при повільних відповідях — критично важливо для перевірки таймаутів:
// Фіксована затримка
_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 випадково
Це ключова перевага 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"}"""));
Це найцінніший сценарій для WireMock. Інструменти на кшталт Polly додають retry-логіку, але як переконатись, що вона дійсно працює? Потрібен сервер, який відповідає по-різному на різні запити.
// 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)));
[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 — патерн, який після певної кількості послідовних помилок «відкриває ланцюг» і на певний час перестає намагатись зробити запит, відразу кидаючи виняток. Це захищає від каскадних збоїв.
// Налаштування 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());
}
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 (для тестування всього 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().MockHttpMessageHandler
Використовуйте для:
Переваги: Швидко, просто, без зайвих залежностей
WireMock.Net
Використовуйте для:
Переваги: Реальний мережевий стек, детальний лог
Замініть MockHttpMessageHandler-тести зі статті 14 на WireMock.Net:
WireMock.Net до залежностей тестового проєкту.IAsyncLifetime з запуском/зупинкою WireMock.Authorization: Bearer у отриманому запиті (через _server.LogEntries).Очікуваний результат: Тести проходять, у логах WireMock видно реальні HTTP-запити.
StripePaymentService Polly Retry Policy (3 спроби при 5xx, з фіксованою затримкою 100ms для тестів)._server.LogEntries.Count(), що зроблено рівно 3 HTTP-запити.Створіть повноцінний інтеграційний тест для ендпоінту POST /api/payments:
WireMockWebApplicationFactory, що підмінює URL платіжного шлюзу на WireMock.succeeded.failed.WithDelay) → перевірте таймаут HttpClient спрацьовує, ендпоінт повертає 504.ResetMappings() і ResetLogEntries().Ми освоїли весь арсенал інструментів для тестування HTTP у .NET: від MockHttpMessageHandler до реального WireMock-сервера. У наступній статті — перейдемо від інструментів до мистецтва написання тестів. Поговоримо про Test Smells, патерни та про те, як зробити тестовий код таким же охайним, як production.
HttpClient у Тестах Частина 1: Архітектура та MockHttpMessageHandler
Ваш сервіс викликає платіжний шлюз. Як протестувати це, не знявши реальні гроші? Глибоко вивчаємо архітектуру HttpClient у .NET, проблему socket exhaustion, IHttpClientFactory та патерн MockHttpMessageHandler.
Патерни та Анти-патерни Тестування: Test Smells
Тести, які важче підтримувати ніж production-код — це Test Smells. Розбираємо каталог поганих практик і вивчаємо патерни Object Mother, Test Data Builder та бібліотеку Bogus для чистих і виразних тестів.