← blog · 6 Ekim 2026

Zamana Bağlı Kararsız Testler: Duvar Saati, Yük Altında Düşen Testler, Saniye Yuvarlaması ve Sahte Saat

Testi duvar saatine bağlayan desenler (sabit uyku, kısa zaman aşımı, süre iddiası, anlık bir anda "henüz olmadı" kontrolü) neden tam da yük altında düşer, o anın baskısı PSI ile nasıl ölçülür, saniye yuvarlaması nasıl "vaktinden önce" hatası üretir ve test, ölçtüğü şeyi kaybetmeden nasıl düzeltilir.

Yerelde yüz kez geçen, CI'da arada bir düşen ve yeniden çalıştırınca geçen testlerin en sık kaynaklarından biri zamandır. Test, iki satırı arasında ne kadar süre geçeceğine dair yazılmamış bir varsayım taşır; makine yavaşladığı gün o varsayım bozulur. Bu yazıda bu desenleri, neden tam da yükte düştüklerini, saniye yuvarlamasının ürettiği "vaktinden önce" hatasını ve testin ölçtüğü şeyi kaybetmeden yapılan düzeltmeleri anlatıyorum.

Testi duvar saatine bağlayan desenler

Senkronizasyon yerine uyku. Bir işin bitmesini time.Sleep(100 * time.Millisecond) ile beklemek "bu iş 100 ms'de biter" demektir. Uyku bir alt sınırdır; ne uykunun ne de beklenen işin üst sınırı vardır.

Kısa zaman aşımı ve "N ms içinde bitmeli" iddiası. 5 ms süren bir çağrıya 100 ms vermek yerelde cömert görünür, onlarca sürecin aynı çekirdekleri paylaştığı koşucuda o pay erir. Süre iddia eden bir birim testi de kodu değil, o anki makineyi ölçer.

Anlık bir anda "henüz olmadı" kontrolü. En sinsi olanı budur:

func TestNotDispatchedEarly(t *testing.T) {
	q := NewQueue()
	defer q.Close()
	q.Schedule("rapor", time.Now().Add(2*time.Second))

	time.Sleep(time.Second)
	if q.Dispatched("rapor") {
		t.Fatal("iş vaktinden önce verildi")
	}
}

Uyku iki buçuk saniye sürerse iş meşru olarak verilmiştir ve test, kuyruğun erken çalıştığını söyleyen yalancı bir hata üretir.

Neden tam da yükte düşerler

Hepsi iki satır arasındaki sürenin küçük olduğunu varsayar; yük bu süreyi uzatır. CPU açlığında paralel test paketleri ve aynı koşucudaki işler çekirdekleri paylaşır, CPU kotasını dolduran cgroup o dönemin geri kalanında hiç çalıştırılmaz. Bellek baskısında çekirdek sayfa geri kazanımı yapar, bellek ayırmaya çalışan iş parçacıkları bekleyebilir. Aynı diske yazan başka bir iş bir fsync çağrısını saniyelerce bekletebilir. Çöp toplayıcının duraklamaları ve harcadığı CPU yükte daha çok hissedilir. Yarış dedektörü ise başlı başına bir çarpandır: Go belgelerine göre tipik bir programda bellek kullanımını 5-10 kat, çalışma süresini 2-20 kat artırabilir.

Bir ayrım şart: yük, ürün kodundaki gerçek bir yarışı da görünür kılar. "Yükte düşüyor" demek "test hatalı" demek değildir; tahmin etmek yerine ölçmek gerekir.

O dakikanın baskısını PSI ile ölçmek

Linux 4.20'den beri çekirdek, kaynak beklemelerini PSI (pressure stall information) olarak yayınlar. /proc/pressure/ altındaki cpu, memory ve io dosyalarında some satırı en az bir görevin o kaynağı beklediği zamanın, full satırı boşta olmayan bütün görevlerin aynı anda beklediği zamanın oranıdır. Her satırda son 10, 60 ve 300 saniyenin yüzdeleri ve mikrosaniye cinsinden birikimli total bulunur. Bu dosyalar bütün makineyi gösterir; konteynerin kendi baskısı cgroup v2'de cpu.pressure, memory.pressure ve io.pressure dosyalarındadır.

Testin başında ve sonunda total değerini okuyun, test düştüğünde farkı rapora yazın (Go'da t.Cleanup içinde t.Failed() kontrolüyle). Düşüşler yüksek baskıyla üst üste biniyorsa test büyük olasılıkla zamana bağlıdır; baskı yokken de düşüyorsa sebep başka yerdedir.

Saniye yuvarlaması ve "vaktinden önce" hatası

Math.floor(Date.now() / 1000) ya da kesirli kısmı atan bir sütun zamanı aşağı yuvarlar. "Şundan önce verme" anlamındaki bir sınır aşağı yuvarlanırsa sistem işi gerçekten 999 ms'ye kadar erken verebilir: vakti 12:00:00.700 olan iş 12:00:00 diye kaydedilir, dağıtıcı onu 12:00:00.100'de vakti gelmiş sayar.

Yuvarlamanın yönünü sınırın anlamı belirler. "Şundan önce değil" sınırı yukarı (Math.ceil), "şundan sonra değil" sınırı aşağı yuvarlanır; hata böylece hep güvenli tarafa düşer. Veritabanının ne yaptığını da varsaymayın: MySQL kesirli saniyeyi daha az basamaklı bir sütuna yazarken varsayılan olarak en yakına yuvarlar, kesir yarımın altındaysa sınır yine aşağı iner.

Aynı hata testte ters yönden de gelir: gözlenen zaman saniye, beklenen zaman milisaniye çözünürlüğündeyse zamanında verilmiş iş kâğıt üzerinde erken görünür. İki tarafı aynı çözünürlüğe indirmeden karşılaştırmayın. Süre ölçerken de monoton saat kullanın: tarayıcıda Date.now() yerine performance.now(); Go'da Round, Truncate ve serileştirme time.Now() değerindeki monoton okumayı atar.

Düzeltme desenleri

Sahte saat. Go 1.25 ile kararlı hâle gelen testing/synctest, zamanı arayüz eklemeden testin eline verir: kabarcıktaki bütün goroutine'ler bloklandığında sahte saat bir sonraki olaya atlar.

func TestDispatchBoundary(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		q := NewQueue()
		defer q.Close()
		due := time.Now().Add(2 * time.Second)
		q.Schedule("rapor", due)

		time.Sleep(2*time.Second - time.Millisecond)
		synctest.Wait()
		if q.Dispatched("rapor") {
			t.Fatal("iş vaktinden önce verildi")
		}
		time.Sleep(time.Millisecond)
		synctest.Wait()
		if !q.Dispatched("rapor") {
			t.Fatal("iş vaktinde verilmedi")
		}
	})
}

Test sınırı milisaniye kesinliğinde ölçer ve gerçekte hiç beklemez. Eski sürümlerde aynı etkiyi enjekte edilen bir Clock arayüzü verir.

Koşulla bekleme ve saatten bağımsız iddia. Gerçek saat şartsa uyumak yerine koşulu yoklayın ve zaman aşımını sağlıklı bir koşuda ulaşılmayacak kadar geniş tutun; o süre iddia değil, korkuluktur. "Bir saniye sonra hâlâ verilmemiş olmalı" yerine her zaman doğru olan bir önerme kurun: iş verildiyse, o anda okunan saat vaktinden geride olamaz.

func TestNeverEarly(t *testing.T) {
	q := NewQueue()
	defer q.Close()
	due := time.Now().Add(200 * time.Millisecond)
	q.Schedule("rapor", due)

	deadline := time.Now().Add(30 * time.Second)
	for {
		given := q.Dispatched("rapor")
		now := time.Now()
		if given && now.Before(due) {
			t.Fatalf("iş %v erken verildi", due.Sub(now))
		}
		if given {
			return
		}
		if now.After(deadline) {
			t.Fatal("iş 30 sn içinde verilmedi")
		}
		time.Sleep(10 * time.Millisecond)
	}
}

Sıra önemlidir: önce durum, sonra saat okunur. Ters sırada saat vakitten hemen önce okunabilir, iş arada verilir ve yalancı hata geri gelir. Bu test yükte yanlış alarm vermez, yalnızca uzar. Daha güçlüsü, dağıtıcının işi verirken okuduğu zamanı kaydetmesi ve testin her kayıt için !at.Before(due) doğrulamasıdır.

Süre yerine sayım. "50 ms altında" iddiasının arkasındaki kaygı çoğu zaman fazladan sorgu ya da döngüdeki ağ çağrısıdır. Sayıyı doğrulayın, süreyi kontrollü ortamdaki kıyaslamalara bırakın.

Sonradan çizilen öğeler. Tıklamayla açılan, animasyonla gelen ya da içeriği sonradan yüklenen bir menüde hedef, yük altında henüz yerinde olmayabilir ya da yeniden çizilebilir. Playwright'ta öğeye bir kez tutulan referans yerine her eylemde yeniden çözülen locator kullanın ve aramayı açık menünün içiyle sınırlayın: page.getByRole('menu').getByRole('menuitem', { name: 'Yenile' }). Sıra numarasıyla hedeflemeyin, force: true ile eylem öncesi kontrolleri atlamayın. Zamana bağlı arayüzü de sahte saatle sınayın:

test('oturum uyarısı vaktinden önce çıkmaz', async ({ page }) => {
  await page.clock.install({ time: new Date('2026-01-05T09:00:00') });
  await page.goto('/panel');
  await page.clock.pauseAt(new Date('2026-01-05T09:10:00'));
  const alert = page.getByRole('alert');
  await page.clock.runFor('19:00');
  await expect(alert).toBeHidden();
  await page.clock.runFor('02:00');
  await expect(alert).toBeVisible();
});

toBeHidden() da anlık bir "henüz olmadı" kontrolüdür, ama saat durdurulduğu için arada hiçbir şey değişemez.

Gevşetmek ile düzeltmek arasındaki fark

Kararsız teste ilk tepki genellikle gevşetmektir: zaman aşımını büyütmek, uykuyu uzatmak, yeniden deneme eklemek, tolerans tanımak. Zaman aşımı yalnızca bir korkuluksa onu büyütmek hiçbir şey kaybettirmez. Ama zaman iddianın parçasıysa gevşetmek iddiayı değiştirir: "iş erken verilmez" testine 500 ms tolerans eklemek, tam da yakalaması gereken 400 ms'lik erken dağıtımı geçirir. Uykuyu uzatmak yalnızca düşme olasılığını azaltır ve testi yavaşlatır.

Ölçütüm şu: değişiklikten önce testin neyi kanıtladığını tek cümleyle yazın, sonra o cümlenin hâlâ doğru olup olmadığına bakın. Cümle zayıfladıysa test gevşemiştir, düzelmemiştir.

Karantinaya almanın riskleri

Karantinadaki test hiçbir şeyi korumaz; o özelliği kapsayan tek testse özellik fiilen testsiz kalır. Sahibi ve süresi olmayan kayıt kalıcı olur. Daha önemlisi, kararsızlığın kaynağı bazen üründür: yükte görünen yarış kullanıcıya da aynı yükte görünür ve testi susturmak o sinyali de susturur. Otomatik yeniden deneme aynı etkiyi daha görünmez yaratır; üç denemeden biri geçtikçe kimse fark etmez.

Karantina gerekiyorsa testi atlamayın; engellemeyen modda koşturun, sonuçlarını düştüğü andaki PSI değerleriyle kaydedin, her kayda bir sahip ve bitiş tarihi yazın. Tarih geldiğinde test ya düzeltilir ya da bilinçli olarak silinir. Zamana bağlı testlerin çoğunda düzeltme yukarıdaki desenlerden birini uygulamaktır ve bence karantina listesini yönetmekten ucuzdur.