HttpClient Kullanan Kodu Unit Test Etmek: HttpMessageHandler Stub'ı, MockHttp, IHttpClientFactory ve Typed Client
HttpClient'ın kendisi değil, altındaki HttpMessageHandler taklit edilir; URL'i de eşleştiren bir handler (RichardSzalay.MockHttp), BaseAddress'teki eksik bir slash'ın v2 segmentini sessizce silmesi gibi hataları yakalar. Kendi stub handler'ın, When/Expect ve matcher'lar, JSON body eşleştirme, typed client ve IHttpClientFactory kaydını test etmek; 429/Retry-After, 5xx, timeout, cancellation ve bozuk JSON senaryoları gerçek hata mesajlarıyla.
Sürüm notu: Bu yazıdaki kodlar .NET 10, xUnit.net v3 4.0.3, RichardSzalay.MockHttp 7.1.0 ve Microsoft.Extensions.Http 10.0.15 ile derlenip denenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'a (bir online kitapçı) bu yazıda Shipping module'ü ekleniyor. İlk parçası, kargo firmasının takip API'sini çağıran bir typed client:
public sealed class ParcelTrackingClient(HttpClient http)
{
public async Task<ParcelStatus?> GetStatusAsync(string trackingNumber, CancellationToken ct)
{
using HttpRequestMessage request = new(HttpMethod.Get, $"parcels/{Uri.EscapeDataString(trackingNumber)}?lang=en");
using HttpResponseMessage response = await SendAsync(request, ct);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
return await ReadAsync<ParcelStatus>(response, ct);
}
// SendAsync ve ReadAsync: hata bölümünde
}
Client, IHttpClientFactory üzerinden kaydediliyor ve adres appsettings.json'daki "CarrierBaseUrl": "https://carrier.example.com/api/v2" değerinden geliyor:
public static IHttpClientBuilder AddParcelTracking(this IServiceCollection services, string carrierBaseUrl) =>
services.AddHttpClient<ParcelTrackingClient>(client =>
{
client.BaseAddress = new Uri(carrierBaseUrl);
client.Timeout = TimeSpan.FromSeconds(10);
client.DefaultRequestHeaders.Add("X-Carrier-Account", "BOOKSTORE-TR");
});
Kargo firmasının dokümanına göre takip adresi https://carrier.example.com/api/v2/parcels/{takipNo}. Test, kaydı production'daki gibi yapıyor ve yalnız ağı RichardSzalay.MockHttp'nin handler'ıyla değiştiriyor:
[Fact]
public async Task Registered_client_reads_a_delivered_parcel()
{
MockHttpMessageHandler carrier = new();
carrier.When(HttpMethod.Get, "https://carrier.example.com/api/v2/parcels/BS2026000042TR")
.Respond("application/json", CarrierResponses.Delivered);
ServiceCollection services = new();
services.AddParcelTracking(CarrierResponses.BaseUrl)
.ConfigurePrimaryHttpMessageHandler(() => carrier);
using ServiceProvider provider = services.BuildServiceProvider();
ParcelTrackingClient client = provider.GetRequiredService<ParcelTrackingClient>();
ParcelStatus? status = await client.GetStatusAsync("BS2026000042TR", TestContext.Current.CancellationToken);
Assert.Equal("Delivered", status?.State);
}
CarrierResponses.BaseUrl, appsettings.json'daki değerin aynısı. Test geçer mi?
Cevap: BaseAddress'in Sonundaki Slash
Kalıyor:
failed Bookstore.Shipping.Tests.ParcelTrackingRegistrationTests.Registered_client_reads_a_delivered_parcel (32ms)
Xunit.MicrosoftTestingPlatform.XunitException: RichardSzalay.MockHttp.MockHttpMatchException : Failed to match a mocked request for GET https://carrier.example.com/api/parcels/BS2026000042TR?lang=en
1 backend definitions were evaluated but did not match:
[FAILED] URL matches https://carrier.example.com/api/v2/parcels/BS2026000042TR
AND [SKIPPED] method matches GET
İstek api/v2/parcels/... yerine api/parcels/... adresine gitmiş: v2 kaybolmuş. HttpClient, relative bir adresi BaseAddress'e göre RFC (internet standartlarının yayımlandığı belge serisi) 3986'da tanımlanan kurallarla çözüyor. Bu kurallara göre base adresin son segmenti, sonunda slash yoksa bir "dosya" sayılıyor ve relative adres onun yerine geçiyor. Davranışı bir theory ile sabitledim, dört satır da yeşil:
[Theory]
[InlineData("https://carrier.example.com/api/v2", "parcels/42", "https://carrier.example.com/api/parcels/42")]
[InlineData("https://carrier.example.com/api/v2/", "parcels/42", "https://carrier.example.com/api/v2/parcels/42")]
[InlineData("https://carrier.example.com/api/v2/", "/parcels/42", "https://carrier.example.com/parcels/42")]
[InlineData("https://carrier.example.com/api/v2", "/parcels/42", "https://carrier.example.com/parcels/42")]
public void Relative_uri_is_resolved_against_the_base_address(string baseAddress, string relative, string expected)
{
Uri resolved = new(new Uri(baseAddress), relative);
Assert.Equal(expected, resolved.ToString());
}
Doğru sonucu yalnız ikinci satır veriyor: base adres slash'la biter, relative adres slash'la başlamaz. Başındaki slash bütün path'i siler; sonundaki slash'ın eksikliği son segmenti siler. Düzeltme kayıtta, adres hangi biçimde gelirse gelsin:
// Sonda slash yoksa "parcels/..." son segmentin ("v2") yerine geçer
client.BaseAddress = new Uri(carrierBaseUrl.EndsWith('/') ? carrierBaseUrl : carrierBaseUrl + "/");
Testin asıl değeri ne olduğunda. 08. yazıdaki StubHttpMessageHandler gelen her isteğe aynı yanıtı veriyordu; o stub'la bu test yeşil olurdu ve hata production'da görünürdü: gerçek kargo API'si api/parcels/... için 404 döner, client da bunu "böyle bir kargo yok" diye null'a çevirir. Müşteri, teslim edilmiş siparişinin takip sayfasında "kargo bulunamadı" görür. URL'i eşleştiren bir handler, istek adresini de testin bir parçası yapıyor.
HttpClient Neden Mock'lanmaz: Handler Katmanı
HttpClient'ın GetAsync, PostAsJsonAsync gibi method'ları virtual değil; hepsi sonunda HttpMessageHandler.SendAsync'e iniyor ve o method protected. Bir mocking kütüphanesiyle HttpClient'ı taklit etmeye çalışmak ya hiç çalışmıyor ya da 17. yazıdaki Protected() ve string method adlarına varıyor. Doğru seam handler: HttpClient'ın constructor'ı bir handler alıyor ve istek, gerçek serileştirme, header ve status code işleme kodundan geçip son adımda ağ yerine o handler'a ulaşıyor.
En kısa hâli birkaç satırlık bir stub:
// Zincirin son halkası olduğu için DelegatingHandler değil, HttpMessageHandler'dan türüyor
public sealed class StubHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> respond)
: HttpMessageHandler
{
public List<HttpRequestMessage> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
Requests.Add(request);
return respond(request, ct);
}
}
DelegatingHandler, isteği bir sonraki handler'a ileten ara halkalar için (header ekleyen, log yazan, retry yapan handler'lar); stub ise isteği hiçbir yere iletmiyor, yanıtı kendisi üretiyor. Bu class, zamanlama ve cancellation gibi davranışı kodla yazmak gereken durumlarda (aşağıdaki timeout testi) yeterli. Ama URL, query, header ve body'ye göre farklı yanıt vermek gerektiğinde her test kendi if zincirini yazmaya başlıyor; o noktada bir kütüphane daha okunur.
RichardSzalay.MockHttp: When, Respond ve Matcher'lar
RichardSzalay.MockHttp, HttpMessageHandler'dan türeyen bir MockHttpMessageHandler ve onun üzerinde akıcı bir kurulum API'si veriyor. Yazım günü son sürüm 7.1.0 (8 Ağustos 2026), lisansı MIT (kısıtlaması çok az, kısa bir açık kaynak lisansı). Paket .NET 6 ve .NET Standard için derlenmiş, .NET 10 test projesinde ek ayar gerekmeden çalışıyor.
public sealed class ParcelTrackingClientTests
{
private const string Parcel = "https://carrier.example.com/api/v2/parcels/BS2026000042TR";
// Her test için yeni handler: When/Expect tanımları başka teste taşınmaz
private readonly MockHttpMessageHandler carrier = new();
private ParcelTrackingClient CreateClient() =>
new(new HttpClient(carrier) { BaseAddress = new Uri("https://carrier.example.com/api/v2/") });
[Fact]
public async Task Delivered_parcel_is_read_with_its_events()
{
carrier.When(HttpMethod.Get, Parcel).Respond("application/json", CarrierResponses.Delivered);
ParcelStatus? status = await CreateClient().GetStatusAsync("BS2026000042TR", TestContext.Current.CancellationToken);
Assert.NotNull(status);
Assert.Equal(new DateTimeOffset(2026, 11, 14, 15, 42, 0, TimeSpan.Zero), status.DeliveredAtUtc);
Assert.Equal(["Picked up", "Delivered to recipient"], status.Events.Select(e => e.Description));
}
[Fact]
public async Task Unknown_parcel_returns_null()
{
carrier.When(HttpMethod.Get, Parcel).Respond(HttpStatusCode.NotFound);
ParcelStatus? status = await CreateClient().GetStatusAsync("BS2026000042TR", TestContext.Current.CancellationToken);
Assert.Null(status);
}
[Fact]
public async Task Tracking_number_is_escaped_in_the_path()
{
MockedRequest escaped = carrier.When("https://carrier.example.com/api/v2/parcels/BS%2042%2FTR")
.Respond(HttpStatusCode.NotFound);
await CreateClient().GetStatusAsync("BS 42/TR", TestContext.Current.CancellationToken);
Assert.Equal(1, carrier.GetMatchCount(escaped));
}
}
When(method, url) bir "backend definition" tanımlıyor: eşleşen her isteğe, kaç kez gelirse gelsin, aynı yanıt dönüyor. URL'de * joker karakter olarak kullanılabiliyor (.../parcels/*). Tanımda query string yoksa URL eşleşmesi query'ye bakmıyor; yukarıdaki istekler ?lang=en ile gitti ve eşleşti. Query'yi de koşul yapmak için WithQueryString("lang", "en") (fazladan parametreleri görmezden gelir) ya da WithExactQueryString(...) (fazladan parametre varsa eşleşmez) ekleniyor. Diğer matcher'lar: WithHeaders, WithContent (body'nin tamamı), WithPartialContent (body'de geçen bir parça), WithFormData, WithJsonContent<T> ve her şeyi kapsayan With(request => ...).
Respond'un overload'ları status code, content type ve body'nin farklı kombinasyonlarını alıyor; header gerekiyorsa Respond(request => new HttpResponseMessage(...)) ile yanıtı kendin kuruyorsun. GetMatchCount(mockedRequest) bir tanımın kaç kez eşleştiğini söylüyor; son test, boşluk ve / içeren bir takip numarasının path'te %20 ve %2F olarak kaçışlandığını böyle doğruluyor. Uri.EscapeDataString unutulsaydı /, adresi parcels/BS 42 ve TR diye iki segmente bölerdi.
Expect, VerifyNoOutstandingExpectation ve JSON Body
Expect, When ile aynı API'ye sahip ama farklı çalışıyor: her expectation yalnız bir kez ve tanımlandığı sırayla eşleşiyor, VerifyNoOutstandingExpectation() da eşleşmemiş expectation kaldıysa testi kırıyor. Sipariş kargoya verilirken gönderilen body'yi doğrulamak için:
[Fact]
public async Task Shipment_is_created_with_the_order_number_and_weight()
{
carrier.Expect(HttpMethod.Post, "https://carrier.example.com/api/v2/shipments")
.WithJsonContent<ShipmentRequest>(s => s.OrderNumber == "BS-2026-000042" && s.WeightKg == 1.2m)
.Respond(HttpStatusCode.Created, "application/json", """{ "trackingNumber": "BS2026000042TR" }""");
string tracking = await CreateClient().CreateShipmentAsync(
new ShipmentRequest("BS-2026-000042", "Ada Lovelace", "Ankara", "06100", 1.2m),
TestContext.Current.CancellationToken);
Assert.Equal("BS2026000042TR", tracking);
carrier.VerifyNoOutstandingExpectation();
}
Bu hâliyle kaldı:
Xunit.MicrosoftTestingPlatform.XunitException: RichardSzalay.MockHttp.MockHttpMatchException : Failed to match a mocked request for POST https://carrier.example.com/api/v2/shipments
The next request expectation failed to match:
[MATCHED] URL matches https://carrier.example.com/api/v2/shipments
AND [MATCHED] method matches POST
AND [FAILED] JSON request body matches custom ShipmentRequest predicate
Client body'yi JsonContent.Create ile yazıyor ve o, web varsayılanlarını kullanıyor: property adları camelCase (orderNumber, weightKg). WithJsonContent<T> ise body'yi System.Text.Json'ın varsayılan ayarlarıyla okuyor; bu ayarlar property adlarında büyük-küçük harfe duyarlı olduğu için OrderNumber null, WeightKg 0 kalıyor ve predicate false dönüyor. İkinci parametreye client'ın kullandığı ayarları vermek yetiyor:
.WithJsonContent<ShipmentRequest>(
s => s.OrderNumber == "BS-2026-000042" && s.WeightKg == 1.2m, JsonSerializerOptions.Web)
Mesajın güzel tarafı, her matcher'ın sonucunu ayrı satırda göstermesi: URL ve method tuttu, body tutmadı. NSubstitute ve Moq'un argüman matcher'larına göre hatayı bulmak daha kolay.
Hangi durumda hangisi? Sorgu tipindeki çağrılar (takip durumu okumak) için When: test kaç kez ve hangi sırayla çağrıldığını umursamıyor. Sistemin dışına etkisi olan çağrılar (kargo kaydı açmak, iptal etmek) için Expect ve VerifyNoOutstandingExpectation: 16. ve 17. yazılarda yalnız dışa etkisi olan çağrıları doğrulama kuralının HTTP karşılığı. Önemli bir ayrıntı: MockHttp'nin dokümanına göre bekleyen bir expectation varken When tanımları hiç eşleşmiyor (handler BackendDefinitionBehavior.Always ile oluşturulmadıysa); ikisini aynı testte karıştırırken bunu bil.
Typed Client ve IHttpClientFactory
Testlerin çoğu client'ı new ParcelTrackingClient(new HttpClient(carrier) { BaseAddress = ... }) ile doğrudan kuruyor. Typed client'ın constructor'ı bir HttpClient istiyor; testte o HttpClient'ı kendin oluşturup handler'ı vermek, IHttpClientFactory'yi taklit etmekten hem kısa hem dürüst. IHttpClientFactory.CreateClient'ı NSubstitute ile kursan da sonunda yine bir handler'lı HttpClient döndürmen gerekiyor; mock, araya yalnız bir katman ekliyor.
Factory'nin kendisine ait tek şey kayıt: BaseAddress, Timeout, varsayılan header'lar ve handler zinciri. Önce Tahmin Et'teki hata tam orada olduğu için kaydı da en az bir testle korumak gerekiyor. ConfigurePrimaryHttpMessageHandler, factory'nin zincirinin en altındaki handler'ı değiştiriyor; kayıttaki her şey gerçek kalıyor:
[Fact]
public async Task Registered_client_sends_the_account_header_and_the_language()
{
carrier.Expect(HttpMethod.Get, "https://carrier.example.com/api/v2/parcels/BS2026000042TR")
.WithQueryString("lang", "en")
.WithHeaders("X-Carrier-Account", "BOOKSTORE-TR")
.Respond("application/json", CarrierResponses.Delivered);
ParcelTrackingClient client = CreateRegisteredClient(out ServiceProvider provider);
using ServiceProvider _ = provider;
await client.GetStatusAsync("BS2026000042TR", TestContext.Current.CancellationToken);
carrier.VerifyNoOutstandingExpectation();
}
private ParcelTrackingClient CreateRegisteredClient(out ServiceProvider provider)
{
ServiceCollection services = new();
services.AddParcelTracking(CarrierResponses.BaseUrl)
.ConfigurePrimaryHttpMessageHandler(() => carrier);
provider = services.BuildServiceProvider();
return provider.GetRequiredService<ParcelTrackingClient>();
}
Önce Tahmin Et'teki hatanın stack trace'inde (yukarıda kısalttığım kısım) LoggingHttpMessageHandler ve LoggingScopeHttpMessageHandler satırları vardı: factory'nin kendi zinciri testte de çalışıyor. Bu testler ServiceCollection için Microsoft.Extensions.DependencyInjection paketine ihtiyaç duyuyor; Shipping module'ü ise yalnız Microsoft.Extensions.Http'ye bağlı.
Hata Senaryoları: 429, 5xx, Timeout, Cancellation ve Bozuk JSON
Client, kargo tarafındaki her sorunu tek bir exception tipine çeviriyor; çağıran kod HttpRequestException, TaskCanceledException ve JsonException ayrımını bilmek zorunda kalmıyor:
private async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
HttpResponseMessage response;
try
{
response = await http.SendAsync(request, ct);
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
throw new CarrierException($"Carrier did not respond within {http.Timeout.TotalSeconds}s.", innerException: ex);
}
if (response.StatusCode == HttpStatusCode.TooManyRequests || (int)response.StatusCode >= 500)
{
HttpStatusCode status = response.StatusCode;
TimeSpan? retryAfter = response.Headers.RetryAfter?.Delta;
response.Dispose();
throw new CarrierException($"Carrier returned {(int)status} {status}.", status, retryAfter);
}
return response;
}
429 (Too Many Requests) ve Retry-After header'ı için yanıtı elle kuruyoruz; 5xx kodları için bir theory yeterli:
private static ParcelTrackingClient CreateClient(HttpMessageHandler handler, TimeSpan? timeout = null) =>
new(new HttpClient(handler)
{
BaseAddress = new Uri("https://carrier.example.com/api/v2/"),
Timeout = timeout ?? TimeSpan.FromSeconds(10),
});
private Task<ParcelStatus?> GetStatus(HttpMessageHandler handler, TimeSpan? timeout = null) =>
CreateClient(handler, timeout).GetStatusAsync("BS2026000042TR", TestContext.Current.CancellationToken);
[Fact]
public async Task Rate_limit_carries_the_retry_after_delay()
{
carrier.When(Parcel).Respond(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests)
{
Headers = { RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(30)) },
});
CarrierException ex = await Assert.ThrowsAsync<CarrierException>(() => GetStatus(carrier));
Assert.Equal(HttpStatusCode.TooManyRequests, ex.StatusCode);
Assert.Equal(TimeSpan.FromSeconds(30), ex.RetryAfter);
}
[Theory]
[InlineData(HttpStatusCode.InternalServerError)]
[InlineData(HttpStatusCode.BadGateway)]
[InlineData(HttpStatusCode.ServiceUnavailable)]
public async Task Server_errors_surface_as_carrier_exception(HttpStatusCode status)
{
carrier.When(Parcel).Respond(status);
CarrierException ex = await Assert.ThrowsAsync<CarrierException>(() => GetStatus(carrier));
Assert.Equal(status, ex.StatusCode);
Assert.Null(ex.RetryAfter);
}
Timeout ve cancellation'ı ayırt etmek bu client'ın en kolay bozulan kısmı. HttpClient.Timeout dolduğunda .NET bir TaskCanceledException fırlatıyor ve içine bir TimeoutException koyuyor; çağıranın kendi token'ı iptal edildiğinde ise TimeoutException yok. Kendi stub'ımızla sonsuza kadar bekleyen bir kargo yazıyoruz:
[Fact]
public async Task Slow_carrier_hits_the_client_timeout()
{
StubHandler slow = new(async (_, ct) =>
{
await Task.Delay(Timeout.InfiniteTimeSpan, ct);
return new HttpResponseMessage(HttpStatusCode.OK);
});
CarrierException ex = await Assert.ThrowsAsync<CarrierException>(
() => GetStatus(slow, timeout: TimeSpan.FromMilliseconds(200)));
Assert.Equal("Carrier did not respond within 0.2s.", ex.Message);
Assert.IsType<TimeoutException>(ex.InnerException?.InnerException);
}
[Fact]
public async Task Caller_cancellation_is_not_reported_as_a_carrier_error()
{
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(
TestContext.Current.CancellationToken);
StubHandler slow = new(async (_, ct) =>
{
await cts.CancelAsync();
await Task.Delay(Timeout.InfiniteTimeSpan, ct);
return new HttpResponseMessage(HttpStatusCode.OK);
});
await Assert.ThrowsAsync<TaskCanceledException>(
() => CreateClient(slow).GetStatusAsync("BS2026000042TR", cts.Token));
}
catch olmadan timeout'un ham hâli şöyle görünüyor:
System.Threading.Tasks.TaskCanceledException : The request was canceled due to the configured HttpClient.Timeout of 0.2 seconds elapsing.
---- System.TimeoutException : A task was canceled.
-------- System.Threading.Tasks.TaskCanceledException : A task was canceled.
Timeout testi gerçekten 200 ms sürüyor: HttpClient.Timeout gerçek saatle çalışıyor ve 11. yazıdaki FakeTimeProvider onu ilerletemiyor. Bu yüzden testte süreyi kısa tut ve production değerini (10 saniye) testte kullanma. Cancellation testinde ise iptal, stub'ın içinden, istek gerçekten handler'a ulaştıktan sonra tetikleniyor; böylece "istek yolda iken iptal" durumu deneniyor.
Son senaryo, okunamayan bir body. deliveredAtUtc alanına "tomorrow" gelince ReadFromJsonAsync şu exception'ı fırlatıyor:
System.Text.Json.JsonException: The JSON value could not be converted to Bookstore.Shipping.ParcelStatus. Path: $.deliveredAtUtc | LineNumber: 0 | BytePositionInLine: 88.
Client bunu CarrierException'a sarıyor ve orijinali InnerException'da bırakıyor; test ikisini de kontrol ediyor:
CarrierException ex = await Assert.ThrowsAsync<CarrierException>(() => GetStatus(carrier));
JsonException json = Assert.IsType<JsonException>(ex.InnerException);
Assert.Equal("$.deliveredAtUtc", json.Path);
Shipping testleri toplam 17; hepsi bir saniyenin altında bitiyor, ağa tek istek gitmiyor.
Üretimde Dikkat
Resilience handler'ı unit test'te test etme. Production kaydına Microsoft.Extensions.Http.Resilience'ın AddStandardResilienceHandler()'ı eklendiğinde 429 ve 5xx yanıtları retry, timeout ve circuit breaker'dan geçiyor. Bunu ConfigurePrimaryHttpMessageHandler ile kayıt üzerinden test etmek mümkün ama testler retry'ların gerçek bekleme süreleri kadar uzuyor ve sonuç, Polly'nin (resilience kütüphanesi) varsayılan ayarlarına bağlı hâle geliyor. Bu yazıdaki unit testler client'ın tek bir yanıta ne yaptığını doğruluyor; resilience politikasının uçtan uca davranışını Integration ve E2E (uçtan uca) Test serisinin 04. yazısı (Dış Bağımlılıkları Sınırda Taklit Etmek) WireMock.Net ile gerçek bir HTTP sunucusuna karşı deniyor. Kayıt testi yalnız adresi, header'ları ve timeout'u kontrol etsin.
Yanıt örneklerini kaynağıyla sakla. CarrierResponses class'ındaki JSON'u kargo firmasının dokümanından ya da kayıtlı gerçek bir yanıttan al, dosyanın başına nereden geldiğini yaz. 08. yazıda söylediğimiz gibi bu testler adapter'ın bilinen biçimi doğru okuduğunu garanti ediyor; biçimin hâlâ o olduğunu garanti etmiyor.
Hata çıktılarında gerçek adres görünür. MockHttp'nin mesajı tam URL'i yazıyor; CI log'una müşteri e-postası ya da token içeren bir query string düşmesin diye test verisinde yalnız uydurma değerler kullan.
Sık Düşülen Tuzaklar
BaseAddress olmadan relative URL. Typed client'ı testte new HttpClient(handler) ile kurup BaseAddress'i unutunca test isteğe hiç ulaşmadan kalıyor:
System.InvalidOperationException : An invalid request URI was provided. Either the request URI must be an absolute URI or BaseAddress must be set.
Bu iyi bir hata, en azından yüksek sesle kalıyor. Önce Tahmin Et'teki sessiz hâli (slash eksikliği) için kayıt testi ve URL'i eşleştiren bir handler gerekiyor.
Handler'ı testler arasında paylaşmak. "Her testte yeni handler oluşturmak israf" diye MockHttpMessageHandler'ı static bir field'a koyan bir test class'ı:
public sealed class SharedCarrierTests
{
// Bütün class için tek handler: tasarruflu görünüyor
private static readonly MockHttpMessageHandler Carrier = new();
...
}
İki testten biri aynı URL için 404, diğeri teslim edilmiş bir kargo tanımlıyor. Tek tek çalıştırınca ikisi de yeşil; birlikte çalıştırınca:
failed Bookstore.Shipping.Tests.SharedCarrierTests.Unknown_parcel_returns_null (2ms)
Assert.Null() Failure: Value is not null
Expected: null
Actual: ParcelStatus { TrackingNumber = BS2026000042TR, State = Delivered, DeliveredAtUtc = 11/14/2026 3:42:00 PM +00:00, Events = System.Collections.Generic.List`1[Bookstore.Shipping.ParcelEvent] }
İlk eşleşen When kazanıyor ve önceki testin tanımı hâlâ handler'da duruyor. Hangisinin kalacağı testlerin çalışma sırasına bağlı. xUnit her test için class'ın yeni bir instance'ını oluşturuyor; handler'ı instance field'ında tutmak bu sızıntıyı kendiliğinden önlüyor.
Body'yi farklı JSON ayarlarıyla okumak. WithJsonContent örneğindeki gibi, client camelCase yazarken matcher büyük-küçük harfe duyarlı okuyorsa predicate hiç tutmaz. Matcher'a client'ın kullandığı JsonSerializerOptions'ı ver.
Timeout'u cancellation sanmak. catch (TaskCanceledException) tek başına iki farklı durumu yutuyor: kargo yavaş (tekrar denenebilir) ve kullanıcı sayfayı kapattı (hiçbir şey yapma). InnerException is TimeoutException filtresi ve iki ayrı test bu ayrımı korur.
Küçük Bir Deneme
★☆☆ Relative_uri_is_resolved_against_the_base_address theory'sine beşinci bir satır ekle: base adres https://carrier.example.com/api/v2/, relative adres ../v3/parcels/42. Sonucu tahmin et, sonra çalıştır.
★★☆ ParcelTrackingClient'a CancelShipmentAsync(string trackingNumber, CancellationToken ct) ekle (DELETE shipments/{trackingNumber}). Testini Expect ve VerifyNoOutstandingExpectation ile yaz; 404'te ne yapacağına sen karar ver ve onu da test et.
★★★ Kayda AddStandardResilienceHandler() ekle ve Server_errors_surface_as_carrier_exception'ı kayıt üzerinden çalışacak şekilde değiştir. Test süresi ne oldu, handler kaç istek gördü (GetMatchCount)? Bu testi unit test projesinde tutar mıydın?
Bir sonraki yazıda test verisine dönüyoruz: Bogus'ta Faker<T> kuralları, Türkçe locale, seed ve strict mode; AutoFixture'da Build, customization ve [AutoData]. 1.000 kitaplık bir katalog ve geçerli ISBN (International Standard Book Number; kitabın 13 haneli uluslararası kimlik numarası) üreten bir Faker yazacağız.