Mert Özen Her Satırda Daha İleri
Test ve Kalite TDD — #06

Bug Fix ve Legacy Kodda TDD: Önce Kırmızı Test, Characterization ve Sprout ile Yeni Kod

Mert Özen 14 Kas 2026 10 dk 3 görüntülenme
Bug Fix ve Legacy Kodda TDD: Önce Kırmızı Test, Characterization ve Sprout ile Yeni Kod

Bug düzeltirken TDD önce hatayı en düşük doğru seviyede yeniden üreten kırmızı bir testle başlar; testi olmayan legacy kodda ise önce characterization testlerle bugünkü davranış kaydedilir, yeni kural ayrı bir sprout'ta test-first büyütülüp eskiye tek satırla bağlanır. Testsiz bir CheckoutService'te bug'ı kaydetmiş bir characterization testin düzeltmeden sonra neden kırmızıya döndüğünü ve iki test türünün farkını gerçek çıktılarla 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'un ödeme adımını hesaplayan CheckoutService 2019'dan beri production'da ve tek bir testi yok. Bir bug raporu geliyor ve ona dokunmadan önce, class'ın bugün ne yaptığını üç test ile kayıt altına alıyorsun. Üçü de yeşil. Sonra bug için kırmızı bir test yazıyorsun, düzeltmeyi yapıyorsun, bug testi yeşile dönüyor. Ama önceki üç testten biri kırmızıya dönüyor.

Bu kötü bir şey mi? Düzeltmeyi geri mi almalısın, testi mi güncellemelisin, yoksa testi silmeli misin? Cevap, o testin ne tür bir test olduğuna bağlı. Yazının ortasında bu kırmızıyı gerçek çıktısıyla göreceğiz.

Bug'la Başlayan TDD ve Doğru Test Seviyesi

TDD (Test-Driven Development; önce testi, sonra kodu yazma yaklaşımı) yalnız yeni kod yazarken değil, bug düzeltirken de aynı sırayı izler: önce kırmızı test, sonra düzeltme. Debugging ve Problem Çözme serisinin "Bug'ı Reproduce Etmek" yazısı bunun nedenlerini anlattı: kırmızısını görmediğin bir test bug'ı yakaladığını kanıtlamaz ve düzeltmeden sonra yazılan test, düzeltilmiş koda göre şekillenir. Burada o yazının devamını, yani bu testin TDD akışındaki yerini konuşuyoruz.

Bug raporu şu: "SAVE50 kuponuyla 320 liralık sepet 270 liraya iniyor, ama ödeme sayfasında kargo ücretsiz görünüyor. Kural, indirim sonrası tutar 300 lira ve üzerindeyse kargonun bedava olması." Rapor ödeme sayfasından geldi, ama bu testin ödeme sayfasında yazılması gerektiği anlamına gelmez. Test seviyesini seçerken sorulacak soru: hata hangi seviyede yaşıyor ve onu gösteren en düşük seviye hangisi? Burada kargo kararı backend'de, CheckoutService.Quote içinde veriliyor. Tarayıcıdan başlayan bir E2E (end-to-end) test aynı hatayı yakalar, ama saniyeler sürer, kırıldığında nedenini göstermez ve yalnız bu bug için suite'e kalıcı bir yük ekler. Unit seviyesindeki bir test hatayı tam yerinde gösterir.

Seviyeyi doğru seçmenin bir yan etkisi de var: düzeltmeden sonra test kalıcı bir regresyon korumasına dönüşür. Aynı hata yarın başka bir refactor'da geri gelirse, milisaniyeler içinde kırmızıya döner.

Testi Olmayan Bir Class: CheckoutService

Michael Feathers'ın Working Effectively with Legacy Code (2004) kitabındaki tanım bu yazının çerçevesini çiziyor: legacy kod, yaşından bağımsız olarak testi olmayan koddur. Testi olmadığı için değiştirmesi korkutucudur; değiştirmek için test gerekir, test yazmak için çoğu zaman değiştirmek gerekir. Bizim legacy class'ımız şu:

public class CheckoutService
{
    private readonly ICouponStore _coupons;

    public CheckoutService(ICouponStore coupons)
    {
        _coupons = coupons;
    }

    public CheckoutQuote Quote(List<OrderLine> lines, string? couponCode, string city)
    {
        decimal subtotal = 0;
        foreach (OrderLine line in lines)
        {
            subtotal += line.UnitPrice * line.Quantity;
        }

        decimal shipping = 29.90m;
        if (subtotal >= 300)
        {
            shipping = 0;
        }
        if (city == "Istanbul" || city == "Ankara" || city == "Izmir")
        {
            shipping = shipping * 0.8m;
        }

        decimal discount = 0;
        if (!string.IsNullOrEmpty(couponCode))
        {
            StoredCoupon? coupon = _coupons.Find(couponCode);
            if (coupon != null && coupon.ExpiresOn >= DateTime.Today)
            {
                if (coupon.IsPercentage)
                {
                    discount = Math.Round(subtotal * coupon.Value / 100, 2);
                }
                else
                {
                    discount = coupon.Value;
                }
            }
        }

        return new CheckoutQuote(subtotal, discount, shipping, subtotal - discount + shipping);
    }
}

Bu class şanslı bir legacy örneği: kupon store'u constructor'dan geliyor, yani test için bir stub verebiliyoruz. Gerçek hayatta çoğu zaman class bağımlılığını kendisi new'ler ya da static bir method çağırır ve önce bir seam açmak gerekir. Bağımlılık kırma tekniklerinin ayrıntısı ilerideki Refactoring serisinde; bu yazıda testin yazılabildiği ama hiç yazılmadığı durumla ilgileniyoruz.

Characterization Test: Kod Bugün Ne Yapıyor?

Bug'a dokunmadan önce, class'ın bugünkü davranışını kayıt altına alıyoruz. Feathers buna characterization test der ve tekniği bilerek tuhaftır: beklenen değer olarak bildiğin yanlış bir şey yazarsın, testi çalıştırırsın ve hata mesajındaki gerçek değeri beklenen değere kopyalarsın.

[Fact]
public void Small_cart_in_Istanbul()
{
    CheckoutService service = new(new CouponStoreStub());

    CheckoutQuote quote = service.Quote(Cart(45.50m, 2), couponCode: null, city: "Istanbul");

    Assert.Equal(new CheckoutQuote(0m, 0m, 0m, 0m), quote);
}
failed Bookstore.Checkout.Tests.CheckoutServiceCharacterizationTests.Small_cart_in_Istanbul (8ms)
  Expected: CheckoutQuote { Subtotal = 0, Discount = 0, Shipping = 0, Total = 0 }
  Actual:   CheckoutQuote { Subtotal = 91.00, Discount = 0, Shipping = 23.920, Total = 114.920 }

Çıktı zaten ilk ilginç bulguyu verdi: büyükşehir indirimi uygulanmış kargo 23.920, üç ondalık basamakla. decimal çarpımı ölçeği koruduğu için 29,90 × 0,8 böyle görünüyor. Değer olarak 23,92 ile aynı, ama bir yerde string'e çevrilip ekrana ya da faturaya basılıyorsa fark görünür. Bunu not ediyoruz, düzeltmiyoruz: characterization test'in amacı davranışı yargılamak değil, kaydetmek. Gerçek değeri kopyalayıp aynı yolla iki senaryo daha ekliyoruz:

Assert.Equal(new CheckoutQuote(91.00m, 0m, 23.920m, 114.920m), quote);   // Small_cart_in_Istanbul
Assert.Equal(new CheckoutQuote(320m, 0m, 0m, 320m), quote);              // Cart_over_300_in_Bursa
Assert.Equal(new CheckoutQuote(320m, 50m, 0m, 270m), quote);             // Fixed_coupon_on_a_320_cart_in_Bursa

Üç test yeşil. Üçüncüsüne dikkat: bug raporundaki durumu birebir kaydetti, kargo sıfır. Characterization test bunu bilemez; kodun ne yaptığını yazar, ne yapması gerektiğini değil.

Bu teknik, tam olarak Kent Beck'in Canon TDD'de hata olarak saydığı şey: gerçek değeri beklenen değere kopyalamak. Çelişki yok, çünkü iki test farklı soruların cevabı. TDD testi bir spesifikasyon yazar ve beklenen değeri davranıştan türetir; kopyalamak, testin çift kontrol değerini yok eder. Characterization test ise bir ölçüm yapar; beklenen değeri koddan almak tam olarak amacıdır.

Bug'ı Düzeltmek: Kırmızı Test, Sonra Kod

Şimdi bug için TDD testi. Bu test ayrı bir class'ta duruyor, çünkü bir spesifikasyon: davranışın ne olması gerektiğini söylüyor.

[Fact]
public void Free_shipping_threshold_applies_to_the_discounted_subtotal()
{
    // Bug #482: SAVE50 ile 320'lik sepet 270'e iniyor ama kargo bedava görünüyor
    StoredCoupon save50 = new() { Code = "SAVE50", Value = 50m, ExpiresOn = DateTime.Today.AddDays(30) };
    CheckoutService service = new(new CouponStoreStub(save50));
    List<OrderLine> lines = [new OrderLine { Isbn = "978-0321125217", UnitPrice = 80m, Quantity = 4 }];

    CheckoutQuote quote = service.Quote(lines, couponCode: "SAVE50", city: "Bursa");

    Assert.Equal(29.90m, quote.Shipping);
    Assert.Equal(299.90m, quote.Total);
}
failed Bookstore.Checkout.Tests.CheckoutServiceTests.Free_shipping_threshold_applies_to_the_discounted_subtotal (17ms)
  Expected: 29.90
  Actual:   0

Kırmızı, bug raporunu birebir yeniden üretiyor. Düzeltme küçük: kupon bloğunu kargo bloğunun üstüne taşımak ve eşiği indirim sonrası tutarla karşılaştırmak (if (subtotal - discount >= 300)). Testleri koşunca "Önce Tahmin Et"teki tablo ortaya çıkıyor:

failed Bookstore.Checkout.Tests.CheckoutServiceCharacterizationTests.Fixed_coupon_on_a_320_cart_in_Bursa (7ms)
  Expected: CheckoutQuote { Subtotal = 320, Discount = 50, Shipping = 0, Total = 270 }
  Actual:   CheckoutQuote { Subtotal = 320, Discount = 50, Shipping = 29.90, Total = 299.90 }
  total: 4
  failed: 1
  succeeded: 3

Bug testi yeşil, iki characterization test yeşil, bug'ı kaydetmiş olan characterization test kırmızı. Bu tam olarak beklenen ve iyi bir haber: kırmızı, düzeltmenin davranışı tam olarak bug'ın olduğu yerde değiştirdiğini, başka hiçbir yerde değiştirmediğini söylüyor. O testin görevi bitti; aynı senaryoyu artık doğru beklentiyle bug testi taşıyor. Kırmızı characterization testi siliyoruz.

Burada önemli olan, bu kararı bilinçli vermek. Bir characterization test kırmızıya döndüğünde iki ihtimal var: ya bilerek değiştirdiğin davranışı kaydetmişti (sil ya da yeni davranışla güncelle), ya da istemeden bozduğun bir davranışı yakaladı (düzeltmeyi gözden geçir). İkisini ayırt etmenin yolu, kırmızının bug raporundaki senaryoyla örtüşüp örtüşmediğine bakmak.

Sprout: Yeni Kuralı Ayrı Yerde Test-first Büyütmek

Aynı hafta yeni bir istek geliyor: Bookstore Club üyeleri kargoyu yarı fiyata ödeyecek. Bu kuralı Quote'un ortasına bir if daha ekleyerek yazmak mümkün, ama o zaman yeni kural da testi zor bir method'un içinde doğar. Feathers'ın sprout method ve sprout class teknikleri farklı bir yol önerir: yeni davranışı ayrı, test-first yazılmış bir method ya da class'ta büyüt, eski koda tek satırla bağla.

public sealed class ClubShippingTests
{
    public static TheoryData<bool, decimal, decimal> Cases => new()
    {
        { false, 29.90m, 29.90m },
        { true, 29.90m, 14.95m },
        { true, 23.92m, 11.96m },
        { true, 0m, 0m },
    };

    [Theory]
    [MemberData(nameof(Cases))]
    public void Club_members_pay_half_the_shipping_fee(bool isClubMember, decimal shipping, decimal expected)
    {
        decimal fee = ClubShipping.Apply(shipping, isClubMember);

        Assert.Equal(expected, fee);
    }
}

Test, legacy class'tan bağımsız. Kurulumu yok, stub'ı yok; dört veri satırı kuralın bütün kenarlarını söylüyor: üye olmayan etkilenmez, ücretsiz kargo ücretsiz kalır, büyükşehir indirimli kargo da yarıya iner. Derleyicinin kırmızısından (CS0103: The name 'ClubShipping' does not exist) sonra implementasyon tek satır:

public static class ClubShipping
{
    public static decimal Apply(decimal shipping, bool isClubMember) =>
        isClubMember ? Math.Round(shipping / 2, 2, MidpointRounding.AwayFromZero) : shipping;
}

Şimdi bağlantı. Önce bağlantıyı isteyen testi Quote seviyesinde yazıyoruz: İstanbul'da 91 liralık sepet, kulüp üyesi, kargo 11,96 olmalı. Quote'a varsayılan değeri false olan bir isClubMember parametresi ekleyince kırmızı, bağlantının eksik olduğunu söylüyor:

failed Bookstore.Checkout.Tests.CheckoutServiceTests.Club_members_get_half_price_shipping_at_checkout (7ms)
  Expected: 11.96
  Actual:   23.920

Legacy method'a eklenen tek satır, büyükşehir bloğundan hemen sonra:

shipping = ClubShipping.Apply(shipping, isClubMember);
Test run summary: Passed!
  total: 8
  failed: 0
  succeeded: 8
  skipped: 0
  duration: 246ms

Parametrenin varsayılan değeri olduğu için mevcut çağıranlar değişmedi ve characterization testler yeşil kaldı. Legacy class'ın tamamını teste almadık, hiçbir şeyini yeniden yazmadık; ama dokunduğumuz iki yer, bug ve yeni kural, artık testlerle korunuyor. Kodun test edilen kısmı her değişiklikte biraz büyüyor.

Characterization Test ile TDD Testi Yan Yana

SoruCharacterization testTDD testi
Cevapladığı soruKod bugün ne yapıyor?Kod ne yapmalı?
Beklenen değer nereden gelir?Koddan: çalıştır, gerçek değeri kopyalaDavranıştan: kodu yazmadan önce
İlk kırmızı ne anlatır?Ölçümün yapılmadığını (bilerek yanlış değer)Davranışın henüz olmadığını
Sonradan kırmızıya dönerseDavranış değişti; bilerek mi, kazara mı?Spesifikasyon bozuldu; regresyon
ÖmrüGenelde geçici; yerini spesifikasyon testlerine bırakırDavranış yaşadığı sürece

İki türü ayrı class'larda tutmak bu farkı açıkça gösterir. CheckoutServiceCharacterizationTests içindeki bir test kırmızıya döndüğünde "bunu bilerek mi değiştirdim?" diye sorarsın; CheckoutServiceTests içindeki bir kırmızı ise doğrudan regresyondur.

Üretimde Dikkat

Bug testinin adı ve izi. Testin adına davranışı, yorumuna bug numarasını yaz. "Bug482Test" adı, altı ay sonra kimseye bir şey anlatmaz; "Free_shipping_threshold_applies_to_the_discounted_subtotal" ise kuralı anlatır. Bug numarası, testin neden var olduğunu soran kişiye izi gösterir.

Zamana bağlı legacy kod. CheckoutService, kupon süresini DateTime.Today ile kontrol ediyor. Characterization testlerde kuponun bitiş tarihini DateTime.Today.AddDays(30) yaparak bu bağımlılığın etrafından dolaştık. Süre kuralının kendisini test etmek gerektiğinde, ikinci yazıdaki gibi TimeProvider ile bir seam açmak gerekir; bu da bir refactoring adımıdır ve önce characterization testlerle korunmalıdır.

Characterization testlerin bakımı. Bu testler çok şey bilir: kodun bütün tuhaflıklarıyla birlikte çıktısını. Kod temizlendikçe ve spesifikasyon testleri çoğaldıkça characterization testleri azaltmak gerekir; yoksa her bilinçli davranış değişikliği, onlarca testi güncellemeyi gerektirir. Hangi characterization testinin hangi spesifikasyon testiyle değiştirildiğini commit mesajında yaz.

Sık Düşülen Tuzaklar

Bug fix'te testi düzeltmeden sonra yazmak. Test hiç kırmızı olmamışsa, bug'ı yakaladığına dair bir kanıtın yok. Düzeltmeyi geri al, testin kırmızıya döndüğünü gör, düzeltmeyi tekrar uygula. Bu kontrol bir dakika sürer.

Legacy'nin tamamını teste almadan dokunamayacağını sanmak. 2.000 satırlık bir class için "önce hepsini teste alalım" planı genelde hiç başlamaz. Dokunacağın davranışın etrafına characterization test, yeni davranış için sprout; test edilen alan her değişiklikle büyür.

Characterization testini spesifikasyon sanmak. Kaydettiğin değer bir bug olabilir; bu yazıdaki üçüncü test tam olarak öyleydi. Characterization test kırmızıya döndüğünde onu körü körüne "eski hâline" döndürmek, bilerek yaptığın düzeltmeyi geri almak demek olabilir.

Sprout'u legacy kodla aynı test zorluğunda bırakmak. Yeni class, legacy class'ın bağımlılıklarını alırsa (veritabanı, DateTime.Now), sprout'un amacı kaybolur. Sprout'a yalnız ihtiyaç duyduğu değerleri ver; bu yazıda ClubShipping yalnız bir tutar ve bir bool alıyor.

Küçük Bir Deneme

★☆☆ Bug testini yazmadan önce düzeltmeyi yapsaydın ne olurdu? Düzeltilmiş kodla bug testini yaz, yeşil olduğunu gör; sonra düzeltmeyi geri al. Test kırmızıya döndü mü? Bu iki adımın sırası neden önemli?

★★☆ Quote'a characterization testi ekle: yüzde kuponu (BOOK10), İzmir, 3 adet 12,45 liralık kitap. Çıktıda kupon indirimi ile kargo için başka bir tuhaflık görüyor musun? Gördüğünü spesifikasyon testine mi, not listesine mi yazarsın?

★★★ Kargo hesabının tamamını sprout class'a taşımayı planla: önce characterization testlerle kargo davranışını genişçe kaydet, sonra ShippingFee class'ını test-first yaz, son adımda Quote'taki kargo bloğunu tek çağrıyla değiştir. Hangi characterization testleri bu işten sonra gereksiz hâle geldi?

Serinin son yazısında genel tabloya bakıyoruz: "Is TDD Dead?" tartışması, araştırmaların söyledikleri, TDD'nin işe yaramadığı yerler ve AI coding agent'larıyla TDD.