Unit Test'te Zor Durumlar: Async Kod, Zaman, Exception'lar, Logging ve Background Service'ler
FakeTimeProvider ile test edilen kodda kural şudur: işlemi başlat, saati ilerlet, sonra bekle; önce await edilen retry testi saat hiç ilerlemediği için 2 saniyelik timeout'a takıldı. Backoff'u saf fonksiyon olarak, retry akışını fake saatle, exception'ı tip ve veriyle, critical log'u FakeLogger ile, cancellation'ı ve Channel<T> tüketen BackgroundService'i deterministik testlerle doğrulamak.
Sürüm notu: Bu yazıdaki kodlar .NET 10, xUnit.net v3 4.0.2, Microsoft.Extensions.TimeProvider.Testing 10.11.0 ve Microsoft.Extensions.Diagnostics.Testing 10.11.0 ile derlenip denenmiştir.
Önce Tahmin Et
Sipariş kargoya verildiğinde Bookstore, kargo firmasının API'sine (Application Programming Interface) bir bildirim gönderiyor. Kargo servisi ara sıra 503 dönüyor; bu yüzden bildirim üstel bekleme (exponential backoff) ile en fazla dört kez deneniyor. Zaman, 09. yazıdaki gibi TimeProvider ile dışarıdan alınıyor:
public static class Backoff
{
// 1 sn, 2 sn, 4 sn, ... üst sınır 30 sn
public static TimeSpan For(int attempt) =>
TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, attempt - 1)));
}
public sealed partial class ShipmentNotifier(
ICourierClient courier, TimeProvider time, ILogger<ShipmentNotifier> logger)
{
public const int MaxAttempts = 4;
public async Task NotifyAsync(ShipmentNotice notice, CancellationToken ct)
{
for (int attempt = 1; ; attempt++)
{
try
{
await courier.SendAsync(notice, ct);
return;
}
catch (HttpRequestException ex) when (attempt < MaxAttempts)
{
LogRetry(notice.OrderNumber, attempt, ex.Message);
await Task.Delay(Backoff.For(attempt), time, ct);
}
catch (HttpRequestException ex)
{
LogGaveUp(ex, notice.OrderNumber, attempt);
throw new ShipmentNotificationException(notice.OrderNumber, attempt, ex);
}
}
}
[LoggerMessage(Level = LogLevel.Warning, Message = "Courier call for {OrderNumber} failed on attempt {Attempt}: {Reason}")]
private partial void LogRetry(string orderNumber, int attempt, string reason);
[LoggerMessage(Level = LogLevel.Critical, Message = "Giving up on shipment notice for {OrderNumber} after {Attempts} attempts")]
private partial void LogGaveUp(Exception ex, string orderNumber, int attempts);
}
Test, ilk çağrıda hata verip ikincide başaran bir sahte kargo servisi ve FakeTimeProvider kullanıyor:
[Fact(Timeout = 2000)]
public async Task Naive_await_never_finishes()
{
ScriptedCourier courier = new(failuresBeforeSuccess: 1);
ShipmentNotifier sut = new(courier, time, NullLogger<ShipmentNotifier>.Instance);
await sut.NotifyAsync(Notice, TestContext.Current.CancellationToken);
Assert.Single(courier.Delivered);
}
Gerçek saatle bu test 1 saniye sürerdi. FakeTimeProvider ile ne kadar sürer?
Cevap: Hiç Bitmez
failed (canceled) Bookstore.Notifications.Tests.ShipmentNotifierTests.Naive_await_never_finishes (2s 004ms)
Xunit.MicrosoftTestingPlatform.XunitException: Test execution timed out after 2000 milliseconds
FakeTimeProvider'ın saati kendiliğinden ilerlemez. İlk çağrı hata verdi, servis Task.Delay(1 sn, time) ile beklemeye geçti ve o bekleme, biri saati ilerletene kadar sürüyor. Test ise await ile servisin bitmesini beklediği için saati ilerletecek kimse yok. Timeout olmasa bu test CI'da (Continuous Integration) sonsuza kadar asılı kalırdı; 02. yazıda TestContext.Current.CancellationToken'ı geçirmenin önemini söylerken kastettiğimiz durum buydu. Fake saatle test etmenin kuralı şu: işlemi başlat, saati ilerlet, sonra bekle. Bu yazı bu kuralı ve unit test'in diğer zor köşelerini ele alıyor: retry, exception, log, cancellation ve background service.
Zamanı İlerletmek: Başlat, İlerlet, Bekle
[Fact]
public async Task Transient_failure_is_retried_after_one_second()
{
ScriptedCourier courier = new(failuresBeforeSuccess: 1);
ShipmentNotifier sut = new(courier, time, NullLogger<ShipmentNotifier>.Instance);
Task notifying = sut.NotifyAsync(Notice, TestContext.Current.CancellationToken);
Assert.False(notifying.IsCompleted);
time.Advance(TimeSpan.FromMilliseconds(999));
Assert.False(notifying.IsCompleted);
time.Advance(TimeSpan.FromMilliseconds(1));
await notifying;
Assert.Equal(2, courier.Calls);
Assert.Single(courier.Delivered);
}
NotifyAsync'in döndürdüğü Task bir değişkende tutuluyor, await edilmiyor. Bu noktada servis ilk denemeyi yapmış ve fake saate bağlı bir timer kurmuş durumda. 999 ms ilerletince görev hâlâ bitmemiş; bir milisaniye daha ilerletince timer tetikleniyor, ikinci deneme başarılı oluyor ve görev tamamlanıyor. Test gerçek zamanda milisaniyeler sürüyor ve sınırı tam olarak doğruluyor: bekleme 1 saniyeden kısa değil.
Burada dikkat edilecek bir ayrıntı var: sahte kargo servisi exception'ı senkron fırlatıyor, bu yüzden NotifyAsync ilk Task.Delay'i kurduktan sonra çağırana dönüyor. Gerçek dünyada bir await noktası araya girdiğinde timer'ın kurulduğundan emin olmadan Advance çağırmak, testin zamanlamaya bağlı kalmasına yol açar. Bu testleri 15 kez arka arkaya çalıştırdım, 15'inde de 10 test geçti; deterministik oldukları için. C# Async Programlama serisi async/await'in mekaniğini ayrıntılı anlatıyor; burada önemli olan, testin saati ilerletmeden önce kodun beklediği noktaya gelmiş olması.
Retry, Backoff ve Cancellation
Retry mantığında iki ayrı soru var ve ayrı test edilmeleri kolaylaştırıyor. Birincisi bekleme süreleri: saf bir fonksiyon, zaman gerektirmez.
[Theory]
[InlineData(1, 1)]
[InlineData(2, 2)]
[InlineData(3, 4)]
[InlineData(6, 30)]
[InlineData(10, 30)]
public void Backoff_doubles_up_to_30_seconds(int attempt, int expectedSeconds) =>
Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), Backoff.For(attempt));
İkincisi akış: kaç deneme, ne zaman vazgeçme, vazgeçince ne olur. Bunun için fake saati her bekleme kadar ilerletiyoruz:
[Fact]
public async Task Gives_up_after_four_attempts_with_a_critical_log()
{
ScriptedCourier courier = new(failuresBeforeSuccess: int.MaxValue);
FakeLogger<ShipmentNotifier> logger = new();
ShipmentNotifier sut = new(courier, time, logger);
Task notifying = sut.NotifyAsync(Notice, TestContext.Current.CancellationToken);
time.Advance(TimeSpan.FromSeconds(1));
time.Advance(TimeSpan.FromSeconds(2));
time.Advance(TimeSpan.FromSeconds(4));
ShipmentNotificationException ex =
await Assert.ThrowsAsync<ShipmentNotificationException>(() => notifying);
Assert.Equal("BS-1042", ex.OrderNumber);
Assert.IsType<HttpRequestException>(ex.InnerException);
Assert.Equal(4, courier.Calls);
FakeLogRecord last = logger.LatestRecord;
Assert.Equal(LogLevel.Critical, last.Level);
Assert.Equal("Giving up on shipment notice for BS-1042 after 4 attempts", last.Message);
}
[Fact]
public async Task Cancellation_during_backoff_stops_retrying()
{
ScriptedCourier courier = new(failuresBeforeSuccess: int.MaxValue);
ShipmentNotifier sut = new(courier, time, NullLogger<ShipmentNotifier>.Instance);
using CancellationTokenSource cts = new();
Task notifying = sut.NotifyAsync(Notice, cts.Token);
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => notifying);
Assert.Equal(1, courier.Calls);
}
Gerçek saatle "dört denemeden sonra vazgeçer" testi 1 + 2 + 4 = 7 saniye sürerdi; on retry testi, bir dakikalık bir suite. Fake saatle milisaniyeler. Cancellation testi de aynı şekilde: servis bekleme sırasındayken token iptal ediliyor ve Task.Delay hemen TaskCanceledException ile bitiyor; ikinci deneme hiç yapılmıyor. ThrowsAnyAsync<OperationCanceledException> kullandık, çünkü iptal TaskCanceledException (alt tip) olarak geliyor; 02. yazıda gördüğümüz gibi ThrowsAsync tam tip ister.
Exception ve Log: Doğru Seviyede Assert
Yukarıdaki testte exception üç seviyede doğrulanıyor: tip (ShipmentNotificationException), iş açısından anlamlı veri (OrderNumber) ve neden (InnerException'ın tipi). Mesajın tam metnini doğrulamadık. Exception mesajı genelde log'a ve hata ekranına giden, zamanla değiştirilen bir metin; testi ona bağlamak, yazım düzeltmesinde kırmızı test demek. Kural: çağıranın exception'a göre karar vermesine yarayan şeyi doğrula (tip, property, iç exception), insanın okuması için olan metni doğrulama. 02. yazıdaki "A shipped order cannot be cancelled." mesajını doğrulamamız bir istisna: orada mesaj bir API yanıtına dönüşüyor ve sözleşmenin parçası.
Log'da aynı mantık geçerli. Servis her retry'da bir warning, vazgeçince bir critical log yazıyor. Test yalnız critical olanı doğruluyor, çünkü o bir alarmın kaynağı: operasyon ekibi kargo bildiriminin tamamen başarısız olduğunu bu log'dan öğreniyor. Warning'leri tek tek doğrulamak, retry sayısını log satırlarına kilitlemek demek. FakeLogger'ın LatestRecord'u ve Collector.GetSnapshot()'u bunun için yeterli. 06. yazıda FakeLogger'ın neden ILogger mock'undan iyi olduğunu görmüştük; burada soru "hangi log'u" sorusu ve cevap: davranışın parçası olanı.
BackgroundService ve Channel<T>
Kargo bildirimleri bir Channel<OrderShipped> üzerinden gelen event'lerle tetikleniyor. 09. yazıdaki Humble Object fikriyle döngü ince tutuldu, her mesajın işlenmesi public bir method'a ayrıldı:
// Döngü ince; her mesajın işlenmesi ayrı ve test edilebilir
public sealed class ShipmentEventsConsumer(ChannelReader<OrderShipped> events, ShipmentNotifier notifier)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (OrderShipped shipped in events.ReadAllAsync(stoppingToken))
{
await HandleAsync(shipped, stoppingToken);
}
}
public Task HandleAsync(OrderShipped shipped, CancellationToken ct) =>
notifier.NotifyAsync(new ShipmentNotice(shipped.OrderNumber, shipped.Email, shipped.TrackingCode), ct);
}
[Fact]
public async Task Handling_one_event_sends_one_notice()
{
await CreateConsumer().HandleAsync(
new OrderShipped("BS-1042", "ada@example.com", "TRK-77"), TestContext.Current.CancellationToken);
Assert.Equal("TRK-77", Assert.Single(courier.Delivered).TrackingCode);
}
[Fact]
public async Task Worker_drains_the_channel_until_it_is_completed()
{
ShipmentEventsConsumer consumer = CreateConsumer();
await channel.Writer.WriteAsync(new("BS-1", "ada@example.com", "TRK-1"), TestContext.Current.CancellationToken);
await channel.Writer.WriteAsync(new("BS-2", "alan@example.com", "TRK-2"), TestContext.Current.CancellationToken);
channel.Writer.Complete();
await consumer.StartAsync(TestContext.Current.CancellationToken);
await consumer.ExecuteTask!.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
Assert.Equal(["BS-1", "BS-2"], courier.Delivered.Select(n => n.OrderNumber));
}
İlk test mesaj işlemenin kendisini, döngüden bağımsız test ediyor. İkinci test döngünün sözleşmesini test ediyor: channel'a yazılan her şey sırayla işlenir ve writer tamamlandığında döngü biter. Kilit nokta channel.Writer.Complete(): tamamlanan bir channel'da ReadAllAsync kalan mesajları verip biter, böylece ExecuteTask tamamlanır ve test "biraz bekle, sonra bak" demek zorunda kalmaz. WaitAsync(TimeSpan.FromSeconds(5), ...) bir güvenlik ağı: döngüde bir hata olursa test asılı kalmak yerine 5 saniyede kalır. Arka plan işlerinin genel tasarımı Background Job'lar ve Scheduled Job'lar serisinde; burada yalnız test edilebilir hâli.
async void ve Fire-and-Forget
Test edilemeyen iki async biçim var. async void method'un dönüş değeri yok; çağıran onun ne zaman bittiğini bilemez ve içinden fırlayan exception çağırana değil, senkronizasyon bağlamına gider (bağlam yoksa process'i çökertebilir). Event handler'lar dışında async void yazma; 02. yazıda gördüğümüz gibi xUnit v3 async void test method'unu derlemede reddediyor. Fire-and-forget, yani _ = SendAsync(...) ile başlatılıp beklenmeyen bir görev, aynı sorunu taşır: test servisin döndüğünü görür ama işin bitip bitmediğini göremez; assert ya erken çalışır ya da "biraz bekle" eklenir. Çözüm tasarımda: işi bir queue'ya ya da channel'a koy ve tüketicisini yukarıdaki gibi ayrı test et, ya da görevi döndür ve çağıran beklesin.
Üretimde Dikkat
Retry'ın production'daki hâli genelde kendi döngün değil, bir resilience library'si (Polly ya da Microsoft.Extensions.Http.Resilience) olur. Onları da TimeProvider alacak şekilde yapılandırırsan aynı fake saat tekniği çalışır. Retry, backoff ve jitter'ın sistem tasarımı tarafı System Design serisinin "Reliability Pattern'leri" yazısında.
Testlere bir üst süre koy. Asenkron bir test, beklediği şey hiç gerçekleşmezse asılı kalır. Timeout, WaitAsync ya da MTP (Microsoft Testing Platform) seviyesinde --timeout ile her koşunun bir üst sınırı olsun; asılı bir CI işi, kalan bir testten daha pahalıdır.
Fake saat ile gerçek saati karıştırma. Microsoft'un FakeTimeProvider dokümanı da bunu uyarıyor: aynı testte bir yer TimeProvider.System, başka bir yer fake saat kullanırsa davranış öngörülemez olur. Kodda TimeProvider alan her yere aynı instance'ı ver.
Sık Düşülen Tuzaklar
Thread.Sleep ve Task.Delay ile beklemek. "Arka plan işi bitsin diye 500 ms bekle" yazan bir test hem yavaştır hem de yavaş bir CI makinesinde kalır. Beklemek yerine bitişin sinyalini bekle: dönen Task, tamamlanan bir channel, bir TaskCompletionSource.
.Result ve .Wait() ile async kodu senkron beklemek. Thread'i bloklar; senkronizasyon bağlamı olan ortamlarda klasik deadlock'a yol açar, paralel koşan testlerde thread havuzunu boşuna tüketir. Test method'unu async Task yap ve await et.
Gerçek saate bağlı testler. "Token 30 dakika sonra geçersiz olur" testini gerçek saatle yazmak imkânsız, "1 saniyelik timeout" testini gerçek saatle yazmak ise yavaş ve kırılgan. TimeProvider ile fake saat, ikisini de milisaniyelik ve deterministik yapar.
Her log satırını doğrulamak. Log metinleri sık değişir; yalnız davranışın parçası olan log'ları (alarm üreten, denetim için gereken) doğrula.
Küçük Bir Deneme
★☆☆ Naive_await_never_finishes testini, Transient_failure_is_retried_after_one_second'daki "başlat, ilerlet, bekle" sırasıyla düzelt. Testin süresi ne oldu?
★★☆ Backoff.For'a jitter ekle (beklemeye 0 ile 250 ms arası rastgele bir ek). Rastgeleliği Random yerine dışarıdan verilen bir fonksiyonla al ve hem Backoff_doubles_up_to_30_seconds'ı hem de Gives_up_after_four_attempts_with_a_critical_log'u deterministik tutacak şekilde güncelle.
★★★ ShipmentEventsConsumer'a, bir mesajın işlenmesi ShipmentNotificationException ile bittiğinde döngünün durmayıp sonraki mesaja geçmesini sağlayan bir davranış ekle. Bunu önce bir testle tarif et: üç mesajdan ortadaki her zaman başarısız olsun, birinci ve üçüncü teslim edilsin.
Bir sonraki yazıda example-based testlerin sınırını genişletiyoruz: büyük çıktıları Verify ile snapshot olarak onaylamak ve FsCheck ile "her girdi için doğru olan" property'leri test etmek.