.NET'te zamana bağlı kodu test etmek: TimeProvider ile saati kontrol altına almak
Zamana bağlı kod, test paketlerindeki kararsızlığın en yaygın kaynaklarından biridir. Bir token'ın süresi doldu mu, bir indirim kampanyası bitti mi, bir yeniden deneme ne zaman yapılacak, bir rapor hangi güne yazılacak: bunların hepsi "şu an" bilgisine dayanır. Kod içinde doğrudan DateTime.UtcNow okunduğunda test, sistemin saatine bağlanır. Sonuç tanıdıktır: gece yarısına yakın kırılan testler, CI'da ara sıra zaman aşımına düşen senaryolar ve Thread.Sleep ile şişmiş yavaş test süreleri.
Bu yazıda .NET'teki TimeProvider soyutlamasıyla saati nasıl bir bağımlılığa dönüştürdüğümü, testte sahte saatle zamanı nasıl ilerlettiğimi ve dikkat edilmesi gereken birkaç tuzağı anlatıyorum.
Sorun: saat gizli bir bağımlılıktır
Şu basit sınıfa bakalım:
public sealed class CouponService
{
public bool IsValid(Coupon coupon) =>
coupon.ExpiresAt > DateTimeOffset.UtcNow;
}
Kod doğru görünür ama test etmek zordur. Süresi dolmuş bir kuponu test etmek için geçmiş bir tarih, geçerli bir kuponu test etmek için gelecekte bir tarih uydurursunuz. Asıl kritik durum olan "tam sınırda" davranışı ise neredeyse test edilemez, çünkü testin başladığı an ile UtcNow'ın okunduğu an arasında geçen süreyi kontrol edemezsiniz.
Daha kötüsü, bu bağımlılık imzada görünmez. Sınıfın kurucusuna bakan biri onun saate ihtiyaç duyduğunu anlayamaz. Veri tabanı ya da HTTP istemcisi gibi diğer dış bağımlılıkları zaten enjekte ediyorsak, saati de aynı şekilde ele almak tutarlı olur.
TimeProvider'ı bağımlılık olarak almak
Eskiden her ekip kendi IClock ya da ISystemClock arayüzünü yazardı. Güncel .NET sürümlerinde bunun yerine çerçevenin sunduğu TimeProvider soyut sınıfını kullanmak daha iyi bir seçim; çünkü yalnızca "şu an" değil, zamanlayıcılar, gecikmeler ve geçen süre ölçümü de aynı soyutlamadan geçer.
public sealed class CouponService(TimeProvider time)
{
public bool IsValid(Coupon coupon) =>
coupon.ExpiresAt > time.GetUtcNow();
}
Uygulamada gerçek saati tek bir satırla kaydedersiniz:
builder.Services.AddSingleton(TimeProvider.System);
TimeProvider.System durumsuzdur, bu yüzden singleton olarak paylaşılması sorun yaratmaz. Artık saat, sınıfın imzasında görünen açık bir bağımlılıktır.
Testte saati sahte bir saatle değiştirmek
Testlerde Microsoft.Extensions.TimeProvider.Testing paketindeki FakeTimeProvider kullanılabilir. Bu sınıf, siz söyleyene kadar ilerlemeyen bir saattir:
[Fact]
public void Coupon_expires_exactly_at_expiry_time()
{
var start = new DateTimeOffset(2026, 3, 1, 12, 0, 0, TimeSpan.Zero);
var time = new FakeTimeProvider(start);
var service = new CouponService(time);
var coupon = new Coupon(ExpiresAt: start.AddMinutes(30));
Assert.True(service.IsValid(coupon));
time.Advance(TimeSpan.FromMinutes(30));
Assert.False(service.IsValid(coupon));
}
Bu test belirlenimcidir: hangi gün, hangi saat diliminde ya da ne kadar yavaş bir makinede çalışırsa çalışsın aynı sonucu verir. Başlangıç zamanını her zaman açıkça vermenizi öneririm. Testi okuyan kişi senaryonun hangi tarihte geçtiğini görür ve ay sonu, artık yıl ya da yaz saati geçişi gibi özel tarihleri bilinçli olarak seçebilir.
Gecikmeler ve zamanlayıcılar
Asıl kazanç bekleme içeren kodda ortaya çıkar. Basit bir yeniden deneme döngüsü düşünelim:
public sealed class RetryingSender(IMessageClient client, TimeProvider time)
{
public async Task SendAsync(Message message, CancellationToken ct)
{
for (var attempt = 1; ; attempt++)
{
try
{
await client.SendAsync(message, ct);
return;
}
catch (TransientException) when (attempt < 3)
{
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt));
await Task.Delay(delay, time, ct);
}
}
}
}
Task.Delay'in TimeProvider alan aşırı yüklemesi sayesinde bekleme, verilen saate bağlanır. Aynı yaklaşım PeriodicTimer, CancellationTokenSource zaman aşımı ve WaitAsync için de geçerlidir; bunların da TimeProvider kabul eden kurucuları ve aşırı yüklemeleri vardır. Testte sahte saati ilerlettiğinizde bekleyen görevler gerçek zamanı beklemeden tamamlanır:
var sendTask = sender.SendAsync(message, CancellationToken.None);
time.Advance(TimeSpan.FromSeconds(2));
time.Advance(TimeSpan.FromSeconds(4));
await sendTask;
Assert.Equal(3, client.CallCount);
Burada bir inceliğe dikkat etmek gerekir. Advance çağrısı yalnızca o anda kurulmuş zamanlayıcıları tetikler. Eğer kod, zamanlayıcıyı henüz kurmadan siz saati ilerletirseniz, o bekleme kaçırılır ve test takılır. Asenkron akışlarda bu sıralama her zaman garanti değildir. Pratikte iki yol izliyorum: test edilen bileşenin bir sonraki beklemeye girdiğini gözlemlenebilir kılmak (örneğin sahte istemcinin çağrı sayısını bir TaskCompletionSource ile bildirmesi) ya da beklemeye girildiğinden emin olduktan sonra saati ilerletmek. Testin içine Task.Delay(100) serpiştirmek ise sorunu çözmez, yalnızca kararsızlığı gizler.
Geçen süreyi ölçmek
Performans ölçümü ya da zaman aşımı hesabı için Stopwatch kullanan kod da aynı sorunu taşır. TimeProvider bunun için GetTimestamp ve GetElapsedTime sunar:
var started = time.GetTimestamp();
await ProcessAsync(ct);
var elapsed = time.GetElapsedTime(started);
if (elapsed > SlowThreshold)
logger.LogWarning("İşlem yavaş tamamlandı: {Elapsed}", elapsed);
Sahte saatle bu kod, "işlem eşikten uzun sürerse uyarı yazılır" senaryosunu saniyelerce beklemeden doğrulamanıza izin verir. Duvar saati ile monoton zaman damgası arasındaki fark da korunmuş olur: süre ölçümü için GetUtcNow değil, GetTimestamp kullanmak gerekir, çünkü sistem saati senkronizasyon nedeniyle geri ya da ileri atlayabilir.
Saat dilimi ve "bugün" kavramı
Zamanla ilgili hataların önemli bir kısmı, "şu an"dan çok "bugün" kavramında ortaya çıkar. Bir sunucu UTC'de çalışırken kullanıcı İstanbul'daysa, gece 01:00'de oluşturulan bir sipariş kullanıcıya göre bugüne, sunucuya göre düne aittir. Bu yüzden birkaç kuralı sıkı tutuyorum:
- Saklama ve karşılaştırma her zaman UTC ile yapılır;
DateTimeOffsettercih edilir. - Yerel tarihe çevirme, iş kuralının ihtiyaç duyduğu yerde ve açık bir saat dilimiyle yapılır.
- Sunucunun yerel saat dilimine dayanan
GetLocalNowya daDateTime.Nowiş mantığında kullanılmaz.
var istanbul = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul");
var localToday = DateOnly.FromDateTime(
TimeZoneInfo.ConvertTime(time.GetUtcNow(), istanbul).DateTime);
Sahte saatle bu kodu gün sınırının iki yanındaki anlarla test etmek kolaylaşır: UTC 20:59 ve 21:00 gibi iki başlangıç zamanı vererek tarihin doğru değiştiğini doğrularsınız.
Mevcut kod tabanına geçiş
Büyük bir projede tüm DateTime.UtcNow çağrılarını bir günde değiştirmek gerekmez. Benim izlediğim sıra şöyle:
TimeProvider.System'i DI kapsayıcısına kaydetmek.- Önce süre dolumu, zamanlanmış iş, yeniden deneme ve faturalama gibi zamana duyarlı iş kurallarını taşımak.
- Yeni kodda doğrudan saat okumayı bir analizör kuralı ya da kod inceleme maddesiyle engellemek.
- Statik yardımcılarda saati parametre olarak almak; sınıf yoksa
TimeProvider'ı metot argümanı yapmak yeterlidir.
Statik bir Clock.Now tutup testte değiştirmek cazip görünebilir ama paralel çalışan testlerde paylaşılan durum yeni kararsızlıklar üretir. Enjekte edilen bağımlılık bu riski taşımaz.
Kapanış
Saati bir bağımlılık olarak görmek küçük bir değişiklik gibi durur, ama etkisi büyüktür: testler belirlenimci hâle gelir, bekleme içeren senaryolar milisaniyeler içinde koşar ve sınır durumları gerçekten test edilebilir olur. TimeProvider bunu ekip içinde yeni bir soyutlama icat etmeden, çerçevenin kendi araçlarıyla yapmanızı sağlar. Bir sonraki "ara sıra kırılan" testi incelediğinizde, ilk bakacağınız yerlerden biri kodun saati nereden okuduğu olsun.