Skip to main content
12 min read

Testler Yeşil ama Prod Yanıyor: Test Kalitesi Rehberi

Mustafa Kürşad BaşerMustafa Kürşad BAŞER
Testler Yeşil ama Prod Yanıyor: Test Kalitesi Rehberi

Testler Yeşil ama Prod Yanıyor

Key Takeaways

  • Kapsam oranı bir satırın çalıştırıldığını söyler, doğru olduğunu değil. Tek bir assertion içermeyen test de kapsamı yükseltir.
  • Testin uygulamaya değil davranışa bakması gerekir. Davranış değişmediği halde refactoring’de kırılan test seni korumuyor, yavaşlatıyordur.
  • Her bağımlılığı mock’lamak, kendi kurgunu doğrulayan bir test bırakır elinde. Sahip olmadığın tipi mock’lama.
  • Kaypak bir test, olmayan testten daha zararlıdır; ekibe kırmızıyı yok saymayı öğretir.
  • Cyclomatic complexity sana kaç test yazacağını değil, en fazla kaça ihtiyacın olduğunu söyler. Alt sınır değil, tavan.

Bir sabah düşün. CI yeşil, kapsam raporu %87 gösteriyor, sürüm dün akşam çıkmış. Sonra destek kanalına ilk mesaj düşüyor: bazı siparişler iki kez oluşuyor.

Testler bunu neden yakalayamadı? Cevap çoğu zaman "test yazmamışız" değil. Testler var, çalışıyor, hepsi yeşil yanıyor. Sorun şu ki yanlış şeyi doğruluyorlar.

Bu yazıda test sayısını değil test kalitesini konuşacağız. Kapsam oranının neyi kanıtlayıp neyi kanıtlamadığına, bir testin ne zaman kırılganlaştığına, mock’ların nerede fazla kaçtığına, kaypak testlerin ekibe ne yaptığına ve iyi bir testin neye benzediğine bakacağız. Örnekler Java ve JUnit üzerinden gidecek ama anlatılan hiçbir şey dile özel değil.

%100 kapsam neyi kanıtlar

Kapsam aracı tek bir şey ölçer: testler çalışırken hangi satırlara uğrandığını. Uğramak ile doğrulamak arasında ise dağlar var.

java
public class Sepet {    private final List<Urun> urunler = new ArrayList<>();     public void ekle(Urun urun) {        urunler.add(urun);    }     public void temizle() {        urunler.clear();    }     public BigDecimal toplam() {        BigDecimal toplam = BigDecimal.ZERO;        for (Urun urun : urunler) {            toplam = toplam.add(urun.fiyat());        }        return toplam;    }}
java
@Testvoid toplamiHesaplar() {    Sepet sepet = new Sepet();    sepet.ekle(new Urun("Kalem", new BigDecimal("10.00")));     sepet.toplam();   // çağrıldı, ama dönen değere kimse bakmıyor}

Bu test geçer ve kapsam raporunda toplam() metodunun bütün satırları yeşil görünür. Şimdi asıl mesele: metottaki add çağrısını subtract yapsan bile test yine geçer, çünkü dönen değere bakan kimse yok.

Kapsamın işe yaradığı tek bir yön var, o da negatif yön. %20 kapsam sana bir şey söyler: kodun büyük kısmı hiç çalıştırılmıyor. %95 kapsam ise tek başına hiçbir şey söylemez.

Bu arada, yukarıdaki add yerine subtract numarasını otomatik yapan bir araç var. Mutasyon testi denen yöntem tam olarak bunu yapıyor: kodunda küçük bozmalar üretip her biri için test paketini çalıştırıyor. Bozma hayatta kaldıysa, yani testler hâlâ yeşilse, o satır kapsanmış ama doğrulanmamış demektir. Java tarafında PIT (pitest) bunu JUnit ile birlikte çalıştırıyor. Kapsam oranından çok daha dürüst bir sayı verir, karşılığında paketi birkaç kez çalıştırdığı için yavaştır; bütün projede değil, kritik modüllerde kullanılır.

İkinci bir sorun daha var. Kapsam bir hedef olarak konduğu anda ölçüt olmaktan çıkıp oyuna dönüşür. Marilyn Strathern’in Goodhart yasasını özetleyen cümlesi tam buraya oturuyor: bir ölçüt hedefe dönüştüğünde iyi bir ölçüt olmaktan çıkar. Ekip oranı tutturmak için assertion’sız testler yazmaya başlar ve elinde hem yüksek bir sayı hem de hiçbir güvence olmayan bir paket kalır.

Davranışı mı test ediyorsun, uygulamayı mı

İkinci tuzak daha sinsi, çünkü bu sefer testler gerçekten assertion içeriyor.

java
// Uygulamayı test ediyor: "şu metodu şu argümanlarla çağırdın mı?"@Testvoid indirimUygular() {    Siparis siparis = yeniSiparis(new BigDecimal("100.00"));     siparis.indirimUygula(10);     verify(hesaplayici).yuzdeHesapla(new BigDecimal("100.00"), 10);} // Davranışı test ediyor: "sonuç doğru mu?"@Testvoid yuzdeOnIndirimdeToplamDoksanOlur() {    Siparis siparis = yeniSiparis(new BigDecimal("100.00"));     siparis.indirimUygula(10);     assertEquals(0, new BigDecimal("90.00").compareTo(siparis.toplam()));}

İndirim hesabını yuzdeHesapla yerine başka bir yapıya taşıdığında ilk test kırılır, ikincisi kırılmaz. Oysa kullanıcı açısından hiçbir şey değişmemiştir; yüzde on indirim yine yüzde on indirimdir.

İkinci testteki compareTo da tesadüf değil. assertEquals(new BigDecimal("90.00"), ...) yazsaydın test, indirimin hangi aritmetikle hesaplandığına bağlanırdı: 100.00 değerini 0.90 ile çarpmak 90.0000 verir ve BigDecimal.equals ölçeği de karşılaştırdığı için bu 90.00 ile eşit sayılmaz. Çıkarma yoluyla hesaplasan geçer, çarpma yoluyla hesaplasan kalır. Değer karşılaştırmak istiyorsan compareTo kullan. Aynı tuzak verify satırında da var; Mockito argümanları equals ile eşleştirir.

Buradan işe yarar bir ölçüt çıkıyor. Kodun içini yeniden düzenlediğinde, davranışa hiç dokunmadığın halde testlerin kırılıyorsa yanlış şeyi test ediyorsun demektir. Testin işi seni refactoring sırasında korumak. Her refactoring’de bakım isteyen bir test korumuyor, yük oluyor.

Mock’ları abartmak

Önce bir terim düzeltmesi, çünkü sektörde her sahte nesneye "mock" deniyor. Kategorinin adı aslında test double ve Gerard Meszaros’un xUnit Test Patterns’te yaptığı beşli ayrım daha kullanışlı: dummy yalnızca parametreyi doldurur ve hiç kullanılmaz, fake çalışan ama basitleştirilmiş bir uygulamadır (bellek içi bir repository gibi), stub sabit cevap döndürür, spy yapılan çağrıları kaydeder, mock ise beklenen çağrıları önceden tanımlar ve o çağrılar gelmezse testi düşürür.

Ayrım önemli, çünkü sıkıntı genelde sonuncusunda. Stub kullandığında "bu girdi gelirse şunu döndür" der, sonra sonuca bakarsın. Mock kullandığında "şu metot şu argümanlarla tam bir kez çağrılmalı" dersin. İkincisi, kodun iç akışını teste kopyalamaktan başka bir şey değil.

Her bağımlılığı mock’ladığın bir testin doğruladığı tek şey, mock’ları doğru kurgulamış olmandır. Gerçek bağımlılık farklı davranırsa test bunu göremez. Klasik senaryo: veri tabanı katmanını mock’larsın, testler yeşil geçer, prod’da sorgu bir kısıt ihlaline takılır.

Bir istisnası var ve önemli. Sonucu dışarıda oluşan, geriye değer döndürmeyen işlerde bakabileceğin tek şey çağrının kendisidir: ödeme alındı mı, e-posta gitti mi, mesaj kuyruğa düştü mü. Yazının başındaki "siparişler iki kez oluşuyor" hatasını yakalayacak assertion da zaten verify(odemeServisi, times(1)) satırıdır. Ölçüt şu: bağımlılık senin kontrolündeki bir veri tabanıysa sonuca bak, dışarıdaki bir sisteme yapılan çağrıysa çağrıya bak.

Note

Kendi sahip olmadığın tipi mock’lama. Üçüncü parti bir kütüphanenin arayüzünü mock’ladığında, o kütüphanenin gerçekte nasıl davrandığına dair varsayımını test etmiş olursun. Doğru yol araya kendi arayüzünü koymak ve gerçek bağımlılığı ayrı bir entegrasyon testiyle doğrulamak.

Kaypak testler

Kaypak (flaky) test, kod hiç değişmediği halde bazen geçen bazen kalan testtir ve zararı sandığından büyüktür.

Bir test rastgele kırmızı yanmaya başladığında ekip önce "yine o test" der, sonra yeniden çalıştırır, en sonunda kırmızıyı umursamamayı öğrenir. O noktadan itibaren gerçek bir hatayı bildiren kırmızı da aynı muameleyi görür; tek bir kaypak test bütün paketin güvenilirliğini düşürür.

Kaynakları da bellidir:

  • Koda LocalDateTime.now() gömmek. Gece 23:59’da çalışan test, gün değiştiği anda kalır; çaresi bir Clock enjekte edip testte sabitlemek.
  • Paylaşılan statik durum. Testler tek tek geçer, hep birlikte çalıştıklarında kalır ve hangisinin diğerini bozduğunu bulmak günlerinizi alır.
  • Paralellik: aynı veri tabanı satırına ya da aynı dosyaya dokunan iki test.
  • Ağ: gerçek bir servise çıkan birim testi. O servis yavaşladığı gün paketin de yavaşlar, düştüğü gün paketin de düşer.
  • Sıralama varsayımı. hashCode yazmadığın nesneleri bir HashSet içine koyup sıralarını varsaymak. Kimlik hash’i her çalıştırmada değişir.

Çarelerin çoğu da bilinen araçlarda hazır duruyor. Paylaşılan durum için her teste taze fikstür, veri tabanı için Testcontainers ya da test başına ayrı şema, ağ için WireMock gibi bir sahte sunucu, rastgele veri için sabit tohum. Zaman tarafında Clock enjeksiyonu zaten yeterli.

Geri kalanı için kural basit: kaypak bir testi ya düzelt ya sil. Devre dışı bırakıp "sonra bakarız" demek üçüncü bir seçenek gibi görünür ama değildir; o test bir daha açılmaz.

Piramit mi külah mı

Test piramidi fikri Mike Cohn’un 2009 tarihli Succeeding with Agile kitabıyla yayıldı, Martin Fowler’ın yazılarıyla da yerleşti. Cohn’un kendi katmanları Unit, Service ve UI’dı; bugün yaygın olan birim/entegrasyon/uçtan uca okuması sonradan oturdu. Tabanda çok sayıda birim testi durur: hızlı, ucuz ve kırıldığında yeri belli. Ortada entegrasyon testleri, tepede az sayıda uçtan uca test.

Sahada pek çok ekip bunun tersini üretir. Buna dondurma külahı deniyor: tepede yığınla yavaş uçtan uca test, altta neredeyse hiç birim testi. Gerisi tanıdık; paket kırk dakika sürer, kırıldığında nerede kırıldığı anlaşılmaz ve bir süre sonra kimse sonucu beklemez.

Piramidin asıl anlattığı şey oran değil, maliyet. Bir birim testi milisaniyede çalışır ve sana tek bir sınıfı işaret eder. Uçtan uca test dakikalar sürer ve kırıldığında yalnızca "bir yerde bir şey bozuk" der. İkincisine ihtiyacın var, ama azına.

İyi bir test neye benzer

Aradığım dört özellik var: adı davranışı söyler, tek bir sebeple kırılır, hazırlık ile doğrulama bölümleri ayrıdır ve deterministiktir.

java
// Adı ne test ettiğini söylemiyor, üç ayrı davranışı tek testte doğruluyor.@Testvoid sepetTesti() {    Sepet sepet = new Sepet();    assertEquals(BigDecimal.ZERO, sepet.toplam());     sepet.ekle(new Urun("Kalem", new BigDecimal("10.00")));    assertEquals(new BigDecimal("10.00"), sepet.toplam());     sepet.temizle();    assertEquals(BigDecimal.ZERO, sepet.toplam());}
java
@Testvoid bosSepetinToplamiSifirdir() {    Sepet sepet = new Sepet();                          // hazırlık     BigDecimal toplam = sepet.toplam();                 // eylem     assertEquals(BigDecimal.ZERO, toplam);              // doğrulama} @Testvoid temizlenenSepetinToplamiSifirlanir() {    Sepet sepet = new Sepet();    sepet.ekle(new Urun("Kalem", new BigDecimal("10.00")));     sepet.temizle();     assertEquals(BigDecimal.ZERO, sepet.toplam());}

İkinci hâlde temizle() bozulursa raporda temizlenenSepetinToplamiSifirlanir yazar ve daha açmadan neyin bozulduğunu bilirsin. Birincisinde ise sadece sepetTesti kaldı yazar; içine girip hangi satırda düştüğüne bakman gerekir.

Hazırlık, eylem ve doğrulama ayrımına AAA deniyor. Aralarına konan bir boş satır kadar basit bir şey, testi okunur kılmak için şaşırtıcı derecede etkili.

Kaç test yazmalı

Bu sorunun tahmine dayanmayan bir üst sınırı var. Cyclomatic complexity, bir metottan geçen bağımsız yol sayısını verir ve bütün dalları gezmek için o sayıdan fazla teste ihtiyacın olmaz.

Dikkat: bu bir tavan, taban değil. Peş peşe iki bağımsız if içeren bir metodun değeri 3’tür ama iki test, (true, true) ve (false, false), bütün dalları kapatır. Yani "değeri 5, demek ki 5 test lazım" diye okuyamazsın.

Yine de günlük işte kullanışlı. Değeri 12 olan bir metodu tek testle geçtiysen kapatılmamış dal olduğuna neredeyse emin olabilirsin; sayı sana bir tavan verdiği için tek test o tavanın çok altındadır. Metriğin nasıl hesaplandığını Cyclomatic Complexity yazısında ele almıştım. Ve tabii dal kapsamı bitiş çizgisi değil: sınır koşulları, null girdiler ve hata yolları ayrı testler ister.

Test edilebilirlik aslında bir tasarım sorunudur

Bir sınıfı test etmek zorlanıyorsan sorun genelde testte değil tasarımdadır.

Kurucusunda new ile kendi bağımlılığını üreten bir sınıfı testte değiştiremezsin. static bir metoda gömülü mantığın yerine başka bir şey koyamazsın. Beş ayrı işi yapan bir sınıfı test etmek için beşinin de kurulmasını beklersin.

Bunların hepsinin karşılığı SOLID tarafında duruyor. Bağımlılığı dışarıdan almak Dependency Inversion, sınıfın tek bir sorumluluğunun olması Single Responsibility. Prensipleri S-O-L-I-D Prensipleri Nedir? yazısında ele almıştım. Testin zorlanması, o prensiplerden birinin çiğnendiğine dair en erken sinyal; üstelik kod incelemesinden önce gelir.

Kodun bir kısmını model yazıyorsa

Son bir not, çünkü çalışma biçimimiz değişti. Kodun giderek büyüyen bir kısmı model önerisiyle yazılıyor ve bu, testin rolünü zayıflatmıyor, tam tersini yapıyor.

Üretilen kod genelde makul görünür ve çoğu zaman çalışır. Zor olan, okurken "çalışıyor gibi" ile "doğru" arasındaki farkı gözle ayırmak. Testin işi tam olarak bu ayrımı yapmak. Kodu kimin yazdığı değişse de doğrulamanın kime ait olduğu değişmiyor.

Bir tuzak daha var: testi de aynı modele yazdırırsan, kodun varsayımlarını doğrulayan bir test elde edersin. Kod yanlış anladıysa test de aynı yanlışı onaylar. Beklenen davranışı testte kendin ifade et, hiç değilse assertion’ları.

Sonuç

Bir test paketinin değeri sayısında değil, kırıldığında sana ne söylediğinde. İyi bir paket "şurada şu bozuldu" der. Kötü bir paket ya hiç kırmızı yanmaz ya da o kadar sık yanar ki kimse bakmaz.

Yarın yapabileceğin üç şey var. Kapsam hedefini gündemden çıkar, yerine "bu test kırılırsa ne öğrenirim" sorusunu koy. Kaypak testleri bir listeye yaz ve bu hafta ya düzelt ya sil. Bir sonraki refactoring’de davranış değişmediği halde kaç testin kırıldığını say. O sayı sana test kalitenin gerçek notunu verir, kapsam raporundan çok daha dürüst biçimde.

Frequently Asked Questions

%100 test kapsamı hedeflemeli miyim?
Hayır. Kapsam yalnızca hangi satırların çalıştırıldığını ölçer, sonucun doğrulanıp doğrulanmadığını değil; assertion içermeyen bir test de oranı yükseltir. Düşük kapsam anlamlı bir uyarıdır, yüksek kapsam ise tek başına bir şey söylemez. Oranı hedef yaptığın anda ekip onu oynamaya başlar. Gerçekten bir sayı istiyorsan mutasyon testi skoruna bak, kapsama değil.
Her şeyi mock’lamanın nesi sakıncalı?
Bütün bağımlılıkları sahteyle değiştirdiğinde test, kodun gerçek bağımlılıklarla nasıl çalıştığını değil senin kurgunu doğrular. Gerçek bağımlılık farklı davranırsa bunu göremezsin. Sabit cevap yeten yerde mock yerine stub kullan, sahip olmadığın tipleri de gerçek uygulamalarıyla ayrı entegrasyon testlerinde sına. Tek istisna, sonucu dışarıda oluşan çağrılar: orada bakabileceğin tek şey çağrının kendisi.
TDD şart mı?
Şart değil, ama testi sonraya bırakmanın bir bedeli var: kodu test edilebilir olmaya zorlayan baskı ortadan kalkıyor. Testi önce yazmak seni bağımlılıkları dışarıdan almaya ve sınıfları küçük tutmaya doğal olarak itiyor. TDD’yi benimsemesen bile en azından testi aynı görevde, kodla birlikte yaz.
Kaypak bir testi geçici olarak devre dışı bırakmak olur mu?
"Geçici" olmuyor, o kadarını söyleyeyim. Devre dışı bırakılan test bir daha açılmadığı için elinde hem bakım yükü hem de kapatılmamış bir davranış kalıyor. Sebebi bul ve düzelt; bulamıyorsan sil. En azından o zaman o davranışın test edilmediğini bilirsin.
Birim testi mi entegrasyon testi mi yazmalıyım?
İkisi de gerekli, mesele oran. Birim testleri hızlı çalışır ve kırıldığında yeri gösterir, bu yüzden çoğunluğu onlar oluşturmalı. Entegrasyon testleri parçaların birlikte çalıştığını doğrular; mock’ların gizlediği hataları asıl onlar yakalar. Ama yavaş oldukları için sayılarını dar tut.

Referanslar

Khorikov, V. (2020). Unit Testing: Principles, Practices, and Patterns. Manning. Bir testin değerini dört sütun üzerinden ölçüyor: regresyona karşı koruma, refactoring’e dayanıklılık, geri bildirim hızı ve bakım kolaylığı. Yukarıdaki "iyi bir test neye benzer" listesi benim gündelik kontrol listem; Khorikov’un sütunları ondan daha üst seviyede ve yönetilen/yönetilmeyen bağımlılık ayrımı da bu kitaptan.

Meszaros, G. (2007). xUnit Test Patterns: Refactoring Test Code. Addison-Wesley. Test double taksonomisinin ve test kokularının kanonik kaynağı.

Freeman, S. ve Pryce, N. (2009). Growing Object-Oriented Software, Guided by Tests. Addison-Wesley. Testin tasarımı nasıl yönlendirdiği üzerine.

Beck, K. (2002). Test-Driven Development: By Example. Addison-Wesley. TDD döngüsünün ilk elden anlatımı.

Cohn, M. (2009). Succeeding with Agile. Addison-Wesley. Test piramidinin ilk formüle edildiği yer.

Fowler, M. Mocks Aren't Stubs

Share this post

Link copied!
Mustafa Kürşad Başer
Author

Mustafa Kürşad Başer

Senior Software Engineer

A passionate software engineer who enjoys creating elegant solutions to complex problems. Beyond coding, I am deeply interested in exploring the intersections of technology, art, and human consciousness.