Dış Bağımlılıkları Sınırda Taklit Etmek: WireMock.Net, Sahte SMTP, FakeTimeProvider ve Hata Senaryoları
Dış servisleri kendi client'ını mock'layarak değil, HTTP ve SMTP sınırında taklit et: WireMock.Net ödeme ve kargo API'si, Mailpit e-posta, FakeTimeProvider zaman için. Giden isteği doğrulamayı, retry, timeout ve circuit breaker'ın gerçekten devreye girdiğini, yavaş bir sağlayıcıda POST retry'ının dört ödemeye yol açtığını ve global sahte saatin Polly'yi dondurduğunu gerçek koşularla gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, xUnit.net v3 4.0.3, WireMock.Net 2.31, Microsoft.Extensions.Http.Resilience 10.11, Microsoft.Extensions.TimeProvider.Testing 10.11, Testcontainers for .NET 4.16 ve Mailpit 1.33 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'da sipariş vermek üç dış sisteme dokunuyor: ödeme sağlayıcısının POST /v1/charges endpoint'i, kargo firmasının POST /v1/shipments endpoint'i ve sipariş onayını gönderen SMTP (e-posta gönderim protokolü) sunucusu. Ödeme client'ı AddStandardResilienceHandler ile kayıtlı; testte her denemenin süresi 500 milisaniyeyle, tekrar sayısı varsayılan olan 3 ile sınırlı.
Şimdi ödeme sağlayıcısı yavaşlıyor: her isteğe 2 saniye sonra 200 OK ve bir chargeId ile cevap veriyor. Müşteri iki adet "Clean Code" sipariş ediyor. Üç soru: API müşteriye ne döner? Ödeme sağlayıcısı kaç istek görür? Ve sağlayıcının tarafında kaç ödeme başarılı olarak kaydedilir?
Cevap: Dört İstek, Dört Başarılı Ödeme, Sıfır Sipariş
Testin gerçek sonucu: API 2–4 saniye arasında 503 Service Unavailable ve "Payment provider unavailable" başlıklı bir ProblemDetails döndü, sipariş oluşmadı, stok değişmedi. WireMock.Net'in request log'unda ise dört istek vardı ve sahte sağlayıcı dördüne de iki saniye sonra 200 cevabını verdi. Her deneme 500 milisaniyede kesildi, resilience pipeline'ı bunu geçici hata sayıp yeniden denedi; sağlayıcı ise her isteği işlemeye devam etti.
Gerçek bir ödeme sağlayıcısında bu, müşterinin kartından dört kez para çekilmesi ve Bookstore'da hiçbir siparişin görünmemesi demek. Bunu yakalayan şey bir mock değil, HTTP seviyesinde çalışan sahte bir sunucu oldu: client'ın gerçekten kaç istek attığını ve hangi gövdeyi gönderdiğini yalnız ağın öbür ucunda duran biri görebilir. Bu yazı o sahte uçları kuruyor.
Ödeme ve Kargo API'lerini WireMock.Net ile Taklit Etmek
WireMock.Net, test process'inin içinde gerçek bir HTTP sunucusu açıyor; istekleri kurallarla eşleştirip tanımladığın cevabı dönüyor ve gelen her isteği kaydediyor. WireMockServer.Start() boş bir port seçiyor, adresi Url property'sinden okunuyor. Factory'ye iki sunucu ekledim, biri ödeme biri kargo için, ve typed client'ların base address'lerini 2. yazıda gördüğümüz UseSetting ile bu sunuculara çevirdim:
public sealed class BookstoreApiFactory(SqlServerFixture sql, MailpitFixture mail) : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly string connectionString = sql.ConnectionStringFor($"Bookstore_{Guid.NewGuid():N}");
private Respawner? respawner;
public WireMockServer PaymentApi { get; } = WireMockServer.Start();
public WireMockServer ShippingApi { get; } = WireMockServer.Start();
// InitializeAsync 3. yazıdaki gibi: migration + Respawn hazırlığı
public async Task ResetDatabaseAsync()
{
await using SqlConnection connection = new(connectionString);
await connection.OpenAsync();
await respawner!.ResetAsync(connection);
// Önceki testin stub'ları ve request log'u da silinsin
PaymentApi.Reset();
ShippingApi.Reset();
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.UseSetting("ConnectionStrings:Bookstore", connectionString);
builder.UseSetting("Payments:BaseUrl", $"{PaymentApi.Url}/");
builder.UseSetting("Payments:ApiKey", "test-key");
// Varsayılanlar 2 sn bekleme ve 10 sn deneme süresi; testte kısalt
builder.UseSetting("Payments:Resilience:Retry:Delay", "00:00:00.050");
builder.UseSetting("Payments:Resilience:AttemptTimeout:Timeout", "00:00:00.500");
builder.UseSetting("Shipping:BaseUrl", $"{ShippingApi.Url}/");
builder.UseSetting("Smtp:Host", mail.SmtpHost);
builder.UseSetting("Smtp:Port", mail.SmtpMappedPort.ToString());
// ConfigureTestServices 2. yazıdaki gibi
}
public override async ValueTask DisposeAsync()
{
PaymentApi.Stop();
ShippingApi.Stop();
await base.DisposeAsync();
}
}
Önemli olan, uygulama kodunda hiçbir şeyin değişmemesi. PaymentClient gerçek HttpClient'ıyla, gerçek serialization'ıyla, gerçek header'larıyla ve gerçek resilience pipeline'ıyla çalışıyor; yalnız karşısındaki adres farklı. Resilience ayarlarının configuration'dan okunması da bu yüzden işe yarıyor: Microsoft Learn'deki tabloya göre standart handler'ın retry'ı 2 saniyelik üstel bekleme, attempt timeout'u 10 saniye kullanıyor. Testte bu değerlerle bir hata senaryosu dakikalar sürerdi.
Testlerin ortak hazırlığı her testten önce veritabanını temizleyip bir müşteri ve bir kitap ekliyor, kargo API'sine de başarılı bir cevap tanımlıyor:
public async ValueTask InitializeAsync()
{
await factory.ResetDatabaseAsync();
using IServiceScope scope = factory.Services.CreateScope();
BookstoreDbContext db = scope.ServiceProvider.GetRequiredService<BookstoreDbContext>();
Customer customer = new() { Email = customerEmail, Name = "Ada" };
Book book = new() { Isbn = "978-0132350884", Title = "Clean Code", Price = 54.90m, Stock = 3 };
db.AddRange(customer, book);
await db.SaveChangesAsync(Ct);
(customerId, bookId) = (customer.Id, book.Id);
factory.ShippingApi
.Given(Request.Create().WithPath("/v1/shipments").UsingPost())
.RespondWith(Response.Create().WithStatusCode(201).WithBodyAsJson(new { trackingNumber = "TRK-1001" }));
}
private void PaymentApproves() =>
factory.PaymentApi
// Stub yalnız doğru API key'le gelen isteği eşleştirir
.Given(Request.Create().WithPath("/v1/charges").UsingPost().WithHeader("X-Api-Key", "test-key"))
.RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new { chargeId = "ch_1001", status = "succeeded" }));
Cevabı değil, isteği doğrulamak
Mutlu yol testi sipariş cevabına bakmakla yetinmiyor; sağlayıcıya giden isteği de açıp okuyor:
[Fact]
public async Task Order_charges_payment_ships_and_sends_confirmation()
{
PaymentApproves();
HttpResponseMessage response = await PlaceOrderAsync();
response.AssertCreatedAt("/api/orders/");
OrderDto order = (await response.Content.ReadFromJsonAsync<OrderDto>(Ct))!;
Assert.Equal("TRK-1001", order.TrackingNumber);
// Sağlayıcı tam olarak bir istek gördü; gövdesi sözleşmeye uygun mu?
ILogEntry charge = Assert.Single(factory.PaymentApi.LogEntries);
JsonElement body = JsonDocument.Parse(charge.RequestMessage.Body!).RootElement;
Assert.Equal(109.80m, body.GetProperty("amount").GetDecimal());
Assert.Equal("TRY", body.GetProperty("currency").GetString());
Assert.Equal(order.Reference, body.GetProperty("orderReference").GetString());
Assert.Equal(1, await StockAsync());
MailpitMessage email = Assert.Single(await mail.MessagesToAsync(customerEmail));
Assert.Equal($"Order {order.Reference} confirmed", email.Subject);
}
İki adet 54,90 TL'lik kitap 109,80 TL eder; tutarın, para biriminin ve sipariş referansının isteğe doğru yazıldığını gören tek yer bu log. Assert.Single da ayrıca önemli: mutlu yolda ödeme isteğinin bir kez atıldığını garanti ediyor.
Hata Senaryoları: Retry, Timeout ve Circuit Breaker
Resilience pipeline'ının nasıl kurulacağı ve hangi değerlerin seçileceği ilerideki External Service Integration ve Resilience serisinin konusu. Bu yazının sorusu daha dar: kurulmuş pipeline gerçekten devreye giriyor mu? WireMock.Net'in scenario özelliği, aynı endpoint'e art arda gelen isteklere farklı cevaplar vermeyi sağlıyor:
[Fact]
public async Task Two_transient_failures_are_retried_and_the_order_succeeds()
{
factory.PaymentApi
.Given(Request.Create().WithPath("/v1/charges").UsingPost())
.InScenario("flaky-payments").WillSetStateTo("failed-once")
.RespondWith(Response.Create().WithStatusCode(500));
factory.PaymentApi
.Given(Request.Create().WithPath("/v1/charges").UsingPost())
.InScenario("flaky-payments").WhenStateIs("failed-once").WillSetStateTo("failed-twice")
.RespondWith(Response.Create().WithStatusCode(503));
factory.PaymentApi
.Given(Request.Create().WithPath("/v1/charges").UsingPost())
.InScenario("flaky-payments").WhenStateIs("failed-twice")
.RespondWith(Response.Create().WithStatusCode(200).WithBodyAsJson(new { chargeId = "ch_1002", status = "succeeded" }));
HttpResponseMessage response = await PlaceOrderAsync();
response.AssertCreatedAt("/api/orders/");
Assert.Equal([500, 503, 200], factory.PaymentApi.LogEntries.Select(e => (int)e.ResponseMessage.StatusCode!));
}
Son assertion, retry'ın varlığını dolaylı değil doğrudan kanıtlıyor: sağlayıcı üç istek gördü, ilk ikisi hata, üçüncüsü başarı. Sağlayıcı sürekli 500 döndüğünde istek sayısı 4 oluyor (bir deneme + üç tekrar) ve API 503 dönüyor. Bu test 434 milisaniyede bitti; varsayılan 2 saniyelik bekleme ile aynı senaryo saniyeler sürerdi.
Yavaş sağlayıcı testi, Önce Tahmin Et'teki senaryonun ta kendisi. Burada bir incelik çıktı: ilk denemede assertion Expected: 4, Actual: 1 ile kaldı. WireMock.Net bir isteği log'a cevabı tamamlandığında yazıyor; client 500 milisaniyede vazgeçtiğinde sunucu tarafındaki 2 saniyelik cevaplar hâlâ sürüyordu. Test, log'un dolmasını kısa bir süre bekleyecek şekilde düzeldi:
[Fact]
public async Task Slow_payment_provider_times_out_each_attempt()
{
factory.PaymentApi
.Given(Request.Create().WithPath("/v1/charges").UsingPost())
.RespondWith(Response.Create().WithStatusCode(200).WithDelay(TimeSpan.FromSeconds(2))
.WithBodyAsJson(new { chargeId = "ch_late", status = "succeeded" }));
Stopwatch stopwatch = Stopwatch.StartNew();
HttpResponseMessage response = await PlaceOrderAsync();
stopwatch.Stop();
await response.AssertProblemAsync(HttpStatusCode.ServiceUnavailable);
Assert.InRange(stopwatch.Elapsed, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4));
// WireMock bir isteği cevabı bittiğinde log'a yazar; geciken cevapları bekle
await WaitForRequestsAsync(factory.PaymentApi, expected: 4);
}
Circuit breaker'ı görmek için ayrı bir test class'ı kullandım, çünkü devrenin durumu pipeline'la birlikte host'ta yaşıyor; açık kalan bir devre aynı host'taki sonraki testleri de etkiler. Varsayılan eşikler (en az 100 istek, %10 hata) bir testte tetiklenmeyecek kadar yüksek; WithWebHostBuilder ile yalnız bu test için düşürdüm:
await using WebApplicationFactory<Program> app = factory.WithWebHostBuilder(builder => builder
.UseSetting("Payments:Resilience:CircuitBreaker:MinimumThroughput", "4")
.UseSetting("Payments:Resilience:CircuitBreaker:FailureRatio", "0.5")
.UseSetting("Payments:Resilience:CircuitBreaker:BreakDuration", "00:00:30"));
// ... müşteri ve kitap eklenir, sağlayıcı her isteğe 500 döner
HttpResponseMessage first = await client.PostAsJsonAsync("/api/orders", request, Ct);
int callsAfterFirst = factory.PaymentApi.LogEntries.Count();
Stopwatch stopwatch = Stopwatch.StartNew();
HttpResponseMessage second = await client.PostAsJsonAsync("/api/orders", request, Ct);
stopwatch.Stop();
Assert.Equal(HttpStatusCode.ServiceUnavailable, first.StatusCode);
Assert.Equal(HttpStatusCode.ServiceUnavailable, second.StatusCode);
Assert.Equal(4, callsAfterFirst);
// Devre açık: ikinci sipariş sağlayıcıya hiç ulaşmadı
Assert.Equal(callsAfterFirst, factory.PaymentApi.LogEntries.Count());
Ölçtüğüm koşuda ilk sipariş sağlayıcıya dört istek attı, ikinci sipariş 13 milisaniyede 503 aldı ve istek sayısı 4'te kaldı. Kargo tarafında ise farklı bir karar test ediliyor: kargo API'si 500 döndüğünde sipariş yine 201 ile oluşuyor, TrackingNumber boş kalıyor ve log'a kargonun sonra yeniden deneneceğini söyleyen bir uyarı düşüyor. Bu, testin kodlaştırdığı bir iş kararı; yeniden denemeyi yapacak mekanizma bu yazının konusu değil.
E-posta: Sahte SMTP Sunucusu
E-posta için HTTP taklidi işe yaramaz; uygulama SMTP konuşuyor. Mailpit, gelen mailleri saklayan ve bir HTTP API'siyle okutan küçük bir SMTP sunucusu; MIT (ticari kullanıma da izin veren bir açık kaynak lisansı) lisanslı ve image'ı lisans kabulü istemiyor. Testcontainers'ın generic container builder'ıyla assembly fixture olarak çalışıyor:
public sealed class MailpitFixture : IAsyncLifetime
{
private const int SmtpPort = 1025;
private const int HttpPort = 8025;
private readonly IContainer container = new ContainerBuilder("axllent/mailpit:v1.33.1")
.WithPortBinding(SmtpPort, assignRandomHostPort: true)
.WithPortBinding(HttpPort, assignRandomHostPort: true)
.WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPort(HttpPort).ForPath("/readyz")))
.Build();
private HttpClient api = new();
public string SmtpHost => container.Hostname;
public int SmtpMappedPort => container.GetMappedPublicPort(SmtpPort);
public async ValueTask InitializeAsync()
{
await container.StartAsync();
api = new HttpClient { BaseAddress = new Uri($"http://{container.Hostname}:{container.GetMappedPublicPort(HttpPort)}/") };
}
// Paralel class'lar aynı Mailpit'i paylaşır: silmek yerine alıcıya göre ara
public async Task<List<MailpitMessage>> MessagesToAsync(string address)
{
MailpitSearchResult? result = await api.GetFromJsonAsync<MailpitSearchResult>(
$"api/v1/search?query={Uri.EscapeDataString($"to:\"{address}\"")}");
return result!.Messages;
}
public async ValueTask DisposeAsync()
{
api.Dispose();
await container.DisposeAsync();
}
}
Mailpit bütün test class'ları arasında paylaşılıyor ve class'lar paralel koşuyor. Bir testin başında bütün mailleri silmek (DELETE /api/v1/messages) başka bir class'ın az önce gönderdiği maili yok edebilir; 3. yazıdaki paralel Respawn sorununun aynısı. Bu yüzden her test benzersiz bir müşteri e-postası (ada-{Guid}@example.com) kullanıyor ve Mailpit'in arama API'siyle yalnız kendi alıcısına giden mailleri okuyor. Ödeme reddedildiğinde mail gitmediğini de aynı yöntemle doğruluyoruz: Assert.Empty(await mail.MessagesToAsync(customerEmail)).
Mesajlaşma için de aynı fikir geçerli, ama araç farklı: RabbitMQ ve MassTransit testleri Message Queue serisinin "Message Queue Kodunu Test Etmek: Testcontainers, MassTransit Test Harness ve Contract Testleri" yazısında, Redis'li testler ise ilerideki Redis serisinde.
Zaman: FakeTimeProvider
Siparişin 30 dakika içinde iptal edilebilmesi kuralı unit seviyesinde zaten test edildi (1. yazı). Integration testin sorusu, endpoint'in saati gerçekten TimeProvider'dan okuyup okumadığı. FakeTimeProvider'ı factory'nin ConfigureTestServices'ine koyduğumda iptal testleri geçti, ama sipariş testlerinden biri asılı kaldı:
failed (canceled) Bookstore.Api.IntegrationTests.OrderPlacementTests.Two_transient_failures_are_retried_and_the_order_succeeds (20s 004ms)
Xunit.MicrosoftTestingPlatform.XunitException: Test execution timed out after 20000 milliseconds
Retry'ın iki deneme arasındaki 50 milisaniyelik beklemesi hiç bitmedi. Doğrulamak için aynı testte isteği başlatıp sahte saati arka arkaya birer saniye ilerlettim; istek ancak saat on kez ilerletildikten sonra tamamlandı. Resilience pipeline'ı bekleme ve timeout'ları için DI (dependency injection; service'leri kaydedip dağıtan yapı) container'ındaki TimeProvider'ı kullanıyor; saati dondurduğunda Polly'nin saatini de donduruyorsun. Çözüm, sahte saati yalnız ona ihtiyaç duyan testlere vermek:
public sealed class OrderCancellationTests(BookstoreApiFactory factory) : IClassFixture<BookstoreApiFactory>, IAsyncLifetime
{
private readonly FakeTimeProvider clock = new(new DateTimeOffset(2026, 11, 14, 9, 0, 0, TimeSpan.Zero));
private WebApplicationFactory<Program> app = null!;
public async ValueTask InitializeAsync()
{
await factory.ResetDatabaseAsync();
// Sahte saat yalnız bu class'ın host'unda; resilience pipeline'ı da bu saati kullanır
app = factory.WithWebHostBuilder(builder =>
builder.ConfigureTestServices(services => services.AddSingleton<TimeProvider>(clock)));
// ... müşteri ve clock.GetUtcNow() zamanlı bir sipariş eklenir
}
[Fact]
public async Task Order_can_be_cancelled_within_the_window()
{
clock.Advance(TimeSpan.FromMinutes(29));
HttpResponseMessage response = await CancelAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("Cancelled", (await response.Content.ReadFromJsonAsync<OrderDto>(Ct))!.Status);
}
[Fact]
public async Task Order_cannot_be_cancelled_after_the_window()
{
clock.Advance(TimeSpan.FromMinutes(31));
HttpResponseMessage response = await CancelAsync();
ProblemDetails problem = await response.AssertProblemAsync(HttpStatusCode.Conflict);
Assert.Equal("Cancellation window closed", problem.Title);
}
}
2. yazıdaki kural burada da işliyor: farklı bir registration gerektiği için WithWebHostBuilder kullanılıyor. 29 ve 31 dakika, sınırın iki yanındaki iki noktayı test ediyor; sınırın tam üstü ve diğer kombinasyonlar unit testte kaldı. Bütün solution bu düzenle 23 testle 17,6 saniyede geçti.
Üretimde Dikkat
Önce Tahmin Et'teki dört ödeme gerçek bir risk. Learn'deki resilience sayfası, standart handler'ın varsayılan olarak bütün HTTP method'larında retry yaptığını ve bir kayıt oluşturan POST'u tekrarlamanın veriyi çoğaltabileceğini açıkça yazıyor. Ödeme gibi bir çağrıda ya sağlayıcının idempotency key desteğini kullanıp her siparişe tek bir anahtar gönder, ya da DisableForUnsafeHttpMethods() ile POST retry'ını kapat. Hangisini seçersen seç, bu yazıdaki yavaş sağlayıcı testine "sağlayıcı tarafında tek ödeme" assertion'ını ekle; idempotency key'in header'da gerçekten gittiğini de WireMock log'undan doğrula.
Test ayarlarını production'la karıştırma. 50 milisaniyelik retry bekleme süresi ve 500 milisaniyelik attempt timeout testi hızlandırmak için var; production değerleri appsettings.json'da kalıyor ve testte UseSetting ile yalnız test host'u için eziliyor. Factory'de hangi değerin neden değiştiğini bir satırlık yorumla yaz; altı ay sonra "production'da neden 500 ms?" sorusu gelmesin.
WireMock sunucularını factory'nin ömrüne bağla ve her testten önce Reset() çağır. Stub'lar ve request log'u testler arasında taşınırsa, bir testin "tek istek" assertion'ı bir öncekinin isteğini de sayar.
Sık Düşülen Tuzaklar
Kendi client'ını mock'layıp HTTP katmanını hiç test etmemek. PaymentClient'ı bir interface arkasına alıp mock'lamak sipariş endpoint'inin iş akışını test eder, ama serialization'ı, header'ları, base address'i ve resilience pipeline'ını devre dışı bırakır. Bunu görmek için ChargeRequest'teki OrderReference property'sinin adını Reference yaptım; derleme geçti, mock'lu bir test de geçerdi. WireMock'lu test kaldı:
failed Bookstore.Api.IntegrationTests.OrderPlacementTests.Order_charges_payment_ships_and_sends_confirmation (918ms)
Xunit.MicrosoftTestingPlatform.XunitException: System.Collections.Generic.KeyNotFoundException : The given key was not present in the dictionary.
at System.Text.Json.JsonElement.GetProperty(String propertyName)
Sağlayıcı artık orderReference alanını almıyor; production'da bu, eşleştirilemeyen ödemeler demekti.
WireMock stub'larının gerçek API'den sapması (contract drift). Stub, sağlayıcının API'si hakkındaki senin varsayımın; sağlayıcı bir alanı zorunlu yaptığında ya da bir hata kodunu değiştirdiğinde testler yeşil kalmaya devam eder. Stub'ları sağlayıcının yayınladığı dokümandan ya da sandbox ortamına atılan gerçek isteklerin kayıtlarından üret, bu kayıtları belirli aralıklarla yenile ve sağlayıcının sandbox'ına karşı koşan birkaç testi ayrı bir pipeline adımında tut. Service'ler arası consumer-driven contract testleri Microservices serisinin 7. yazısında; Angular ile API arasındaki sözleşme bir sonraki yazının konusu.
Sahte saati global yapmak. Yukarıdaki asılı kalan test, FakeTimeProvider'ın uygulamanın kendi kodundan çok daha fazlasını etkilediğini gösterdi: resilience bekleme süreleri, timeout'lar, PeriodicTimer kullanan background service'ler. Sahte saati yalnız zamanla ilgili testlerin host'una ver.
Paylaşılan sahte sunucuyu temizlemek. Mailpit'teki bütün mailleri ya da paylaşılan bir WireMock sunucusunun bütün stub'larını test başında silmek, paralel koşan başka bir class'ın verisini de siler. Paylaşılan kaynakta veriyi testin kendi anahtarıyla (benzersiz e-posta, benzersiz sipariş referansı) filtrele.
Küçük Bir Deneme
★☆☆ Ödeme stub'ını .WithBody(new JsonPathMatcher("$.orderReference")) ile yalnız orderReference alanı olan istekleri eşleştirecek şekilde daralt. Property adını tekrar değiştirip testi koştur; bu kez hata hangi satırda ve hangi mesajla geliyor?
★★☆ PaymentClient'a her sipariş için Idempotency-Key header'ı ekle ve yavaş sağlayıcı testini, dört isteğin de aynı anahtarı taşıdığını doğrulayacak şekilde genişlet.
★★★ Kargo API'si için 429 (Too Many Requests) ve Retry-After header'ı dönen bir stub yaz. Kargo client'ına standart resilience handler'ı ekle ve pipeline'ın Retry-After süresine uyup uymadığını WireMock log'undaki zaman damgalarıyla ölç.
API'nin dışarıya baktığı sınırlar artık test altında. Bir sonraki yazıda ters yöne bakıyoruz: Angular arayüzünün güvendiği API sözleşmesi. .NET 10'un OpenAPI 3.1 dokümanını build'de üretmek, breaking change'leri CI'da yakalamak ve Pact'in ne zaman gerektiği.