TDD Nedir? Canon TDD, Test List ve Red-Green-Refactor'ın Asıl Amacı
TDD çok unit test yazmak değil, davranışı küçük adımlarla büyütürken tasarımı sürekli düzeltme disiplinidir: önce test list, sonra listeden tek test, kırmızı, yeşil, isteğe bağlı refactor ve listeyi güncellemek. Kent Beck'in Canon TDD tarifini, Uncle Bob'un three laws anlatımıyla farkını ve Bookstore'un CouponValidator'ı için listenin iki döngüde nasıl değiştiğini gerçek test çıktılarıyla görüyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ve xUnit.net v3 4.0.3 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'a kupon özelliği geliyor: yüzde kuponu (BOOK10 ile sepete %10 indirim), tutar kuponu (SAVE50 ile 50 lira indirim), son kullanma tarihi ve minimum sepet tutarı. İşi iki geliştirici paylaşmış ve ikisi de "TDD (Test-Driven Development; önce testi, sonra kodu yazma yaklaşımı) ile yapacağım" diyerek koda başlamadan önce bir liste yazmış. Birinci liste:
// Liste A
// [ ] %10 kupon, 200 liralık sepette 20 lira indirim verir
// [ ] 50 liralık tutar kuponu, 200 liralık sepette 50 lira indirim verir
// [ ] Tutar kuponu sepet tutarını aşamaz (40 liralık sepete 50 liralık kupon: 40)
// [ ] Süresi geçmiş kupon reddedilir
// [ ] Kupon son gününde hâlâ geçerlidir
// [ ] Minimum tutarın altındaki sepette kupon reddedilir
// [ ] Minimum tutara tam eşit sepet kabul edilir
// [ ] Bilinmeyen kupon kodu?
İkinci liste:
// Liste B
// [ ] CouponKind enum'unu ekle
// [ ] IDiscountStrategy interface'ini ve iki implementasyonunu yaz
// [ ] CouponValidator'a strategy'leri constructor'dan ver
// [ ] ExpiryRule ve MinimumSubtotalRule class'larını yaz
// [ ] Her class için bir test class'ı aç
İkisi de düzenli, ikisi de işi küçük parçalara bölüyor. Soru şu: hangisi TDD'nin başladığı yer olan bir test list, hangisi değil? Ve ikinci listeyle başlayan geliştirici testleri önce yazsa bile neyi kaçırmış olur? Bir cevap seç, yazının ortasında aynı iki listeye döneceğiz.
Kısa Tarihçe: Test-first'ten Canon TDD'ye
TDD, 1990'ların sonunda Kent Beck'in XP (Extreme Programming) pratikleri arasında "test-first programming" adıyla ortaya çıktı. Agile Mindset serisinin XP yazısı bu pratiğin ekip içindeki yerini anlatıyor; burada onu tekrar etmeyeceğiz. Beck, fikri icat etmediğini, yeniden keşfettiğini söyler: eski bir programlama kitabında gördüğü bir öneriyi hatırladığını anlatır. Önce beklenen çıktıyı hazırlamak, sonra programı o çıktıyı üretene kadar yazmak.
Beck'in Test-Driven Development: By Example (Addison-Wesley, 2002) kitabı pratiği XP'den bağımsız bir disiplin olarak tarif etti. Kitapta para birimi örneği küçük adımlarla, her adımda bir test eklenerek büyütülür; "red, green, refactor" ritmi ve test list kavramı da bu kitapla yaygınlaştı. Martin Fowler'ın 2005'te yazıp 2023'te güncellediği TestDrivenDevelopment yazısı aynı döngüyü üç adımda toplar: bir sonraki küçük işlevsellik için test yaz, testi geçirecek kodu yaz, yeni ve eski kodu refactor et.
Yirmi yıl içinde TDD hakkında o kadar çok yanlış anlatım birikti ki Beck, 11 Aralık 2023'te Tidy First? bülteninde "Canon TDD" başlıklı bir yazı yayımladı. Amacı yeni bir yöntem önermek değil, eleştirilerin hedef aldığı şeyin gerçekten TDD olup olmadığını netleştirmekti. Yazının başında da bunun "nasıl TDD yapmalısın" reçetesi olmadığını, kendi yaptığı şeyin tarifi olduğunu vurgular. Bu seride TDD dendiğinde kastettiğimiz şey bu tarif.
Baştan bir yanlış anlamayı kaldıralım: TDD, "çok unit test yazmak" demek değildir. Çok testi olan ama hiç TDD yapılmamış projeler de var, TDD ile yazılmış ama test sayısı mütevazı kalmış projeler de. TDD'nin asıl ürünü, davranışı küçük adımlarla büyütürken tasarımın sürekli düzeltilmesidir. Testler bu sürecin hem aracı hem yan ürünüdür.
Canon TDD: Beş Adım

Beck'in tarifi beş adımdan oluşuyor. Kendi cümlelerimizle:
Birinci adım test list. Yeni davranışın gerçekleşmesi gereken bütün varyasyonları bir listeye yazarsın: temel durum, sınırlar, hata durumları ve mevcut davranışın bozulmaması gereken yerler. Bu bir davranış analizidir. Listeye "hangi class'ı yazacağım" değil, "sistem hangi durumda ne yapmalı" girer.
İkinci adım test yazmak. Listeden tek bir maddeyi seçip onu gerçek, otomatik, çalıştırılabilir bir teste çevirirsin: hazırlık, çağrı ve assertion. Bu sırada birçok tasarım kararı verirsin, ama bunlar çoğunlukla interface kararlarıdır: davranış nasıl çağrılacak, parametreler neler, sonuç nasıl dönecek.
Üçüncü adım testi geçirmek. Sistemi, bu test ve önceki bütün testler geçecek şekilde değiştirirsin. Bu sırada aklına yeni bir senaryo gelirse listeye eklersin, yarım bırakıp ona koşmazsın.
Dördüncü adım isteğe bağlı refactor. Testler yeşilken implementation tasarımını düzeltirsin: tekrar, isimler, sorumlulukların yeri. Beck burada adımı bilerek "isteğe bağlı" diye adlandırır; her yeşilde refactor edilecek bir şey çıkmayabilir.
Beşinci adım döngüye dönmek: liste boşalana kadar ikinci adıma geri dön. Liste her turda değişir: biten madde işaretlenir, yeni fark edilen senaryo eklenir, gereksiz olduğu anlaşılan madde çıkar.
Bu tarifin en ince noktası iki tür tasarımı ayırması. Test yazarken davranışın dışarıdan nasıl görüneceğine, yani interface'e karar verirsin. Kodun içeride nasıl organize olacağına, yani implementation'a ise refactor adımında karar verirsin. Fowler da aynı noktayı vurgular: önce testi düşünmek, kodu yazmadan onun nasıl kullanılacağını düşünmeye zorlar ve interface ile implementation'ı ayırmak birçok programcının zorlandığı bir tasarım becerisidir.
Test List: CouponValidator İçin İlk Liste
Şimdi başlangıçtaki soruya dönelim. Liste A bir test list: her maddesi dışarıdan gözlenebilir bir davranış, çoğu somut sayılarla yazılmış ve hiçbiri kodun içini tarif etmiyor. Liste B ise bir implementation planı. Testleri önce yazsan bile bu listeyle başladığında tasarımı kodu yazmadan vermiş olursun: iki strategy, iki rule class'ı, her class'a bir test class'ı. Testler artık tasarımı keşfetmez, önceden çizilmiş bir tasarımı doğrular. Beck'in test list adımı için saydığı ilk hata tam olarak bu: listeye implementation kararlarını karıştırmak.
Liste A'yı test projesine, ilk testin hemen üstüne yorum olarak koyuyoruz. Listeyi kodun yanında tutmak, her yeşilde onu güncellemeyi hatırlatıyor:
namespace Bookstore.Discounts.Tests;
// Test list: CouponValidator
// [ ] %10 kupon, 200 liralık sepette 20 lira indirim verir
// [ ] 50 liralık tutar kuponu, 200 liralık sepette 50 lira indirim verir
// [ ] Tutar kuponu sepet tutarını aşamaz (50 liralık kupon, 40 liralık sepet: 40)
// [ ] Süresi geçmiş kupon reddedilir
// [ ] Kupon son gününde hâlâ geçerlidir
// [ ] Minimum tutarın altındaki sepette kupon reddedilir
// [ ] Minimum tutara tam eşit sepet kabul edilir
// [ ] Bilinmeyen kupon kodu?
public sealed class CouponValidatorTests
{
[Fact]
public void Percentage_coupon_takes_its_share_of_the_subtotal()
{
Coupon coupon = Coupon.Percentage("BOOK10", 10m);
CouponValidator validator = new();
CouponResult result = validator.Validate(coupon, subtotal: 200m);
Assert.Equal(20m, result.Discount);
}
}
Listenin son maddesi bilerek soru işaretiyle bitiyor. Test list her zaman net cevaplardan oluşmaz; "bunu henüz bilmiyorum" maddeleri de listeye girer ve sırası gelince ya teste dönüşür ya da listeden çıkar. Liste ayrıca bir öncelik sırası da değildir. Hangi maddeyle başlayacağını seçmek ayrı bir beceri: Fowler'ın dediği gibi iyi bir sıra seni önemli tasarım kararlarına hızla götürür. Biz en basit ve en merkezi davranışla, yüzde kuponuyla başladık.
İlk Döngü: Kırmızı, Sonra Yeşil
Testi yazdığımızda Coupon, CouponValidator ve CouponResult diye hiçbir type yok. Test, kodun nasıl kullanılmasını istediğimizi tarif ediyor: kuponu bir factory method'la oluşturmak, validator'a sepet ara toplamını vermek, sonuçtan indirimi okumak. dotnet test çalıştırınca ilk kırmızı derleyiciden geliyor:
tests/Bookstore.Discounts.Tests/CouponValidatorTests.cs(18,9): error CS0246: The type or namespace name 'Coupon' could not be found (are you missing a using directive or an assembly reference?)
tests/Bookstore.Discounts.Tests/CouponValidatorTests.cs(18,25): error CS0103: The name 'Coupon' does not exist in the current context
tests/Bookstore.Discounts.Tests/CouponValidatorTests.cs(19,9): error CS0246: The type or namespace name 'CouponValidator' could not be found (are you missing a using directive or an assembly reference?)
tests/Bookstore.Discounts.Tests/CouponValidatorTests.cs(21,9): error CS0246: The type or namespace name 'CouponResult' could not be found (are you missing a using directive or an assembly reference?)
Derlenmeyen test de kırmızıdır, ama bize henüz bir şey öğretmez. Bu yüzden önce testin derlenmesini sağlayacak kadar kod yazıyoruz ve Validate'i bilerek yanlış bırakıyoruz:
namespace Bookstore.Discounts;
public enum CouponKind { Percentage, FixedAmount }
public sealed record Coupon(string Code, CouponKind Kind, decimal Value)
{
public static Coupon Percentage(string code, decimal percent) => new(code, CouponKind.Percentage, percent);
}
public sealed record CouponResult(decimal Discount);
public sealed class CouponValidator
{
public CouponResult Validate(Coupon coupon, decimal subtotal) => new(0m);
}
Şimdi asıl kırmızıyı görüyoruz (yolları ve stack trace'in framework satırlarını kısalttım):
Assert.Equal() Failure: Values differ
Expected: 20
Actual: 0
at Bookstore.Discounts.Tests.CouponValidatorTests.Percentage_coupon_takes_its_share_of_the_subtotal() in …/CouponValidatorTests.cs:23
…
Test run summary: Failed!
total: 1
failed: 1
succeeded: 0
skipped: 0
duration: 294ms
Bu kırmızı neden önemli? Çünkü testin gerçekten bir şeyi ölçtüğünü kanıtlıyor. Assertion'ı yanlış yazılmış ya da hiç çalışmayan bir test baştan yeşil görünebilir. Kırmızıyı doğru nedenle, yani "beklenen 20, gelen 0" mesajıyla görmek, testin kendisinin de test edilmesidir. Sonra testi geçirecek kodu yazıyoruz. Bu davranışın implementasyonu açık olduğu için doğrudan yazıyoruz:
public CouponResult Validate(Coupon coupon, decimal subtotal) => new(subtotal * coupon.Value / 100m);
Test run summary: Passed!
total: 1
failed: 0
succeeded: 1
skipped: 0
duration: 199ms
Kodun bu kadar açık olmadığı durumlarda ne yapılacağı, yani sabit dönüp sonra genelleştirmek ya da ikinci bir testle implementasyonu zorlamak, bir sonraki yazının konusu.
Refactor ve Listeyi Güncellemek
İlk yeşilde refactor edilecek bir şey yok; adımı bilinçli olarak geçiyoruz. Listeye dönüp ilk maddeyi işaretliyoruz ve ikinci maddeyi, tutar kuponunu, teste çeviriyoruz:
[Fact]
public void Fixed_amount_coupon_takes_its_amount_off()
{
Coupon coupon = Coupon.FixedAmount("SAVE50", 50m);
CouponValidator validator = new();
CouponResult result = validator.Validate(coupon, subtotal: 200m);
Assert.Equal(50m, result.Discount);
}
FixedAmount factory method'unu ekleyip çalıştırınca kırmızı, ilginç bir sayıyla geliyor:
failed Bookstore.Discounts.Tests.CouponValidatorTests.Fixed_amount_coupon_takes_its_amount_off (8ms)
Assert.Equal() Failure: Values differ
Expected: 50
Actual: 100
…
total: 2
failed: 1
succeeded: 1
100, mevcut kodun 50'yi yüzde olarak yorumlamasından geliyor: 200'ün %50'si. Kırmızı tam olarak eksik davranışı anlatıyor. Yeşile en kısa yoldan geçiyoruz:
public CouponResult Validate(Coupon coupon, decimal subtotal)
{
if (coupon.Kind == CouponKind.FixedAmount)
{
return new(coupon.Value);
}
return new(subtotal * coupon.Value / 100m);
}
İki test yeşil. Şimdi refactor adımında implementation tasarımını düşünme zamanı. Listede bekleyen maddeler (son kullanma tarihi, minimum tutar) Validate'e kurallar ekleyecek. İndirim hesabını kurallardan ayırmak, sonraki adımları okunur tutar. Davranışı değiştirmeden if'i bir switch expression'a ve ayrı bir method'a taşıyoruz:
public sealed class CouponValidator
{
public CouponResult Validate(Coupon coupon, decimal subtotal) => new(DiscountFor(coupon, subtotal));
private static decimal DiscountFor(Coupon coupon, decimal subtotal) => coupon.Kind switch
{
CouponKind.Percentage => subtotal * coupon.Value / 100m,
CouponKind.FixedAmount => coupon.Value,
_ => throw new ArgumentOutOfRangeException(nameof(coupon), coupon.Kind, "Unknown coupon kind.")
};
}
Test run summary: Passed!
total: 2
failed: 0
succeeded: 2
skipped: 0
duration: 276ms
Refactor'ın kuralı basit: testler önce yeşil, sonra yine yeşil. Bu adımda yeni davranış eklenmez, testler değiştirilmez. Strategy pattern'e ya da ayrı rule class'larına geçmek için henüz bir neden yok; iki kupon türü için switch yeterli. Beck'in bu adım için uyarısı, tekrarı bir ipucu olarak görmek, emir olarak değil: soyutlamayı ihtiyaç gerçekten belirdiğinde kur.
Şimdi iki döngüden sonra listenin hâline bak:
// Test list: CouponValidator
// [x] %10 kupon, 200 liralık sepette 20 lira indirim verir
// [x] 50 liralık tutar kuponu, 200 liralık sepette 50 lira indirim verir
// [ ] Tutar kuponu sepet tutarını aşamaz (50 liralık kupon, 40 liralık sepet: 40)
// [ ] Süresi geçmiş kupon reddedilir
// [ ] Kupon son gününde hâlâ geçerlidir
// [ ] Minimum tutarın altındaki sepette kupon reddedilir
// [ ] Minimum tutara tam eşit sepet kabul edilir
// [ ] YENİ: %15 kupon, 99,90 liralık sepet: indirim kuruşa yuvarlanır
// [ ] YENİ: Coupon.Percentage(0) ve Percentage(120) kabul edilmez (Coupon'un işi)
// [ ] YENİ: Reddedilen kupon için sonuç nasıl görünecek? (indirim 0 mı, hata mesajı mı?)
// [-] Bilinmeyen kupon kodu: validator'ın değil, kupon arama tarafının işi
// [?] "Bugün" nereden gelecek? Tarih testlerinden önce karar ver
Üç madde döngüler sırasında doğdu. Yüzde formülünü yazarken decimal sonucun kuruştan fazla basamak taşıyabileceği fark edildi. Factory method yazılırken yüzde değerinin sınırları akla geldi, ama bu CouponValidator'ın değil Coupon'un davranışı, o yüzden notunu düştük. Bekleyen red maddeleri de CouponResult'ın şu anki şeklinin yetmeyeceğini söylüyor. Bilinmeyen kupon kodu maddesi ise çıktı: kupon kodunu veritabanında arayan kod başka bir yerde olacak, validator elindeki kuponu değerlendiriyor. "Bugün" sorusu da açık kaldı; test içinde DateTime.Now kullanan bir test, yarın farklı sonuç verir. Bu soruyu bir sonraki yazıda TimeProvider ile cevaplayacağız.
Bu liste, kodun tasarımından çok daha değerli bir belge. Hangi davranışların bilindiğini, hangilerinin soru olduğunu, hangi kararların bilinçli olarak ertelendiğini gösteriyor.
Three Laws ile Canon TDD Arasındaki Fark
TDD'nin bir başka yaygın anlatımı Robert C. Martin'in (Uncle Bob) "three laws of TDD" kurallarıdır. Martin'in 2014 tarihli The Cycles of TDD yazısındaki hâliyle, kendi cümlelerimizle: başarısız bir test olmadan production kodu yazmazsın; testi, başarısız olmaya ya da derlenmemeye yetecek kadar yazarsın; production kodunu da yalnız o testi geçirecek kadar yazarsın. Martin bu kuralları birkaç saniyede bir dönen bir "nano-cycle" olarak tarif eder ve red-green-refactor'ı onun üstünde birkaç dakikada bir dönen bir döngü olarak koyar.
İki anlatım çelişmiyor, ama farklı yere ışık tutuyor. Three laws klavyedeki ritmi tarif eder: test ile kod arasında saniyeler içinde gidip gelmek. Canon TDD ise bu ritmin etrafındaki kararları açıkça ortaya koyar: test list adımı, testten önce bir davranış analizi yapıldığını söyler; isteğe bağlı refactor adımı, tasarımın nerede değiştiğini söyler. Three laws'da test list yoktur, refactor da kuralların içinde geçmez.
Bu fark pratikte iki yanlış anlamayı besledi. Birincisi, TDD'nin katı bir ritüel olduğu, her satırda test ile kod arasında mekanik olarak gidip gelmek gerektiği algısı. Bu algı, deneyimli geliştiricilerin "bu kadar küçük adım bana yavaş geliyor" itirazının kaynağı. Oysa Beck adım boyunu belirsizliğe göre ayarlamayı önerir; bunu bir sonraki yazıda göreceğiz. İkincisi tam tersi yönde: TDD'nin "bütün testleri önce yazıp sonra kodu yazmak" olduğu algısı. Beck, Canon TDD'de bunu açıkça hata olarak sayar. Listenin tamamını baştan teste çevirmek, erken kararlar değiştiğinde hepsini yeniden yazmayı ve uzun süre hiçbir şeyin geçmediği moral bozucu bir dönemi getirir.
Üretimde Dikkat
Test list'in yeri. Listeyi test dosyasında yorum olarak tutmak küçük işler için iyi çalışır, ama yorum olarak commit'e girmesi gerekmez. Bazı ekipler listeyi task'ın açıklamasına ya da pull request açıklamasına taşır. Önemli olan, listenin iş bitene kadar yaşaması ve iş bittiğinde boş olmasıdır. Pull request'te hâlâ "[ ]" maddeleri olan bir liste, ya yarım kalmış bir davranışı ya da bilinçli ertelenmiş bir kararı gösterir; ikisi de review'da konuşulmalı.
Döngü süresi. TDD'nin ritmi testlerin hızına bağlıdır. Bu yazıdaki iki testin koşusu, derleme hariç 200–300 ms sürdü. Döngü başına saniyeler harcamak ritmi korur; tek bir test dosyası için dakikalar beklemek ritmi bozar ve insanı adımları büyütmeye iter. Yavaşlayan bir test suite'inde ilk bakılacak yer gizli I/O'dur: veritabanı, dosya sistemi, ağ. Bu konuyu Unit Test serisi ayrıntılı ele alıyor.
Kırmızıyı görmek. Ekipte "testi yazdım, geçti" cümlesini duyduğunda sor: önce kırmızı oldu mu? Hiç kırmızı olmamış bir test, gerçekten bir şeyi doğruluyor mu bilinmez. Özellikle mevcut koda sonradan eklenen testlerde bir kez kodu bilerek bozup testin kırmızıya döndüğünü görmek ucuz bir sigortadır.
Sık Düşülen Tuzaklar
Tüm testleri baştan yazmak. Listedeki sekiz maddeyi sekiz test olarak yazıp sonra kodu yazmaya başlamak, TDD'ye benzer ama değildir. Üçüncü testte CouponResult'ın şeklini değiştirmeye karar verdiğinde sekiz testin hepsini düzeltirsin ve uzun bir süre boyunca ekranda yalnızca kırmızı görürsün. Liste maddedir, test değil; teste dönüşme anı, sırası geldiğindedir.
Refactor adımını atlamak. Fowler'a göre TDD'yi bozmanın en yaygın yolu bu. Her yeşilde "çalışıyor, devam" denirse kod, testleri geçen ama birbirine yamanmış parçalardan oluşur. Testler orada olduğu için bu dağınıklık en azından güvenle düzeltilebilir, ama düzeltme ertelendikçe pahalılaşır.
Test list'i implementation adımı listesine çevirmek. Liste B bunun örneğiydi. Listede class, interface ya da pattern adı görüyorsan, davranış analizini atlayıp tasarıma geçmişsindir. Test listesinin maddeleri, kodu hiç görmemiş bir ürün sahibinin de okuyup "evet, böyle olmalı" diyebileceği cümlelerdir.
Testi geçirmek için assertion'ı gevşetmek. Kırmızı test inatçıysa assertion'ı silmek ya da beklenen değeri "gerçekte ne geliyorsa o" ile değiştirmek cazip gelir. Beck bunu da Canon TDD'nin hataları arasında sayar: testin çift kontrol değeri kaybolur. Beklenen değeri koddan değil, davranıştan türet.
Küçük Bir Deneme
★☆☆ Listedeki "Tutar kuponu sepet tutarını aşamaz" maddesini teste çevir. Kırmızıyı gördüğünde mesaj tam olarak ne diyor? Yeşile geçmek için gereken en küçük değişiklik ne?
★★☆ Bookstore'un kargo ücreti kuralı için (belirli bir tutarın üstünde kargo bedava, bazı bölgelere ek ücret, ağır siparişlere ağırlık ücreti) koda dokunmadan bir test list yaz. Sonra listeyi tekrar oku: kaç maddede class ya da method adı geçiyor? Onları davranış cümlesine çevir.
★★★ Listedeki maddeleri iki farklı sırayla uygulamayı dene: bir kez "minimum tutar" maddesiyle, bir kez "süresi geçmiş" maddesiyle başla. Her iki denemede CouponResult'ın ilk hâli nasıl çıktı? Sıra, tasarımı nasıl etkiledi?
Bir sonraki yazıda bu listeyi bitiriyoruz: yeşile geçmenin üç yolunu (fake it, triangulation, obvious implementation), adım boyunu belirsizliğe göre ayarlamayı ve son kullanma tarihini TimeProvider ile test-first eklemeyi canlı bir oturumda göreceğiz.