Mert Özen Her Satırda Daha İleri
Yazılım Tasarımı Refactoring — #02

Refactoring'den Önce Güvenlik Ağı: Characterization Test, Approval Testing ve Kör Noktaları Bulmak

Mert Özen 14 Kas 2026 12 dk 1 görüntülenme
Refactoring'den Önce Güvenlik Ağı: Characterization Test, Approval Testing ve Kör Noktaları Bulmak

Testi olmayan legacy kodu refactor etmeden önce bugünkü davranışı characterization test ve approval testing ile kayda almak gerekir; ama ağın gerçekten koruduğunu coverage ve bilinçli mutasyonlarla sınamadan ona güvenilmez. Yazıda testsiz bir InvoiceRenderer Verify ile onaylanıyor, tarih ve culture kaynaklı kırılmalar scrubber'larla gideriliyor, 48 kombinasyonluk snapshot ve yüzde 100 coverage'a rağmen iki mutasyonun nasıl yaşadığı gerçek çıktılarla gösteriliyor.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 (SDK 10.0.402) ile, testler xUnit.net v3 4.0.3, Verify.XunitV3 32.0.0 ve Microsoft.Testing.Extensions.CodeCoverage 18.12.0 ile derlenip denenmiştir.

Önce Tahmin Et: Testi Olmayan Bir Fatura

Bookstore'un InvoiceRenderer class'ı siparişin fatura metnini üretiyor: başlık, kalem satırları, hediye paketi, kupon, kargo, vergi, ödeme talimatı. Üretimdeki dosya 400 satır; bu yazıda davranışını koruyan 111 satırlık kısaltılmış bir sürümüyle çalışıyoruz ve bütün çıktılar bu sürümden. Hiç testi yok. Başlangıcı şöyle:

public string Render(Order order)
{
    var sb = new StringBuilder();
    var now = DateTime.Now;
    sb.AppendLine("BOOKSTORE INVOICE");
    sb.AppendLine("Invoice no: INV-" + now.ToString("yyyyMMdd") + "-" + order.Id.ToString("D6"));
    sb.AppendLine("Date: " + now.ToString("d"));
    sb.AppendLine("Customer: " + order.Customer.Name + " <" + order.Customer.Email + ">");
    if (order.Customer.IsVip)
        sb.AppendLine("VIP customer - thank you for your loyalty!");
    sb.AppendLine(new string('-', 52));
    // ...
    foreach (var item in order.Items)
    {
        // ...
        var lineTotal = item.Book.Price * item.Quantity;
        subtotal += lineTotal;
        if (item.Book.Format == BookFormat.Ebook)
            tax += lineTotal * 0.20m;
        else
            tax += lineTotal * 0.10m;
        // ...
        sb.AppendLine(title.PadRight(26) + item.Quantity.ToString().PadLeft(3) + " x " + item.Book.Price.ToString("N2").PadLeft(8) + lineTotal.ToString("N2").PadLeft(12));
    }
    // ... kupon, kargo, toplamlar, ödeme talimatı

Görev, bu class'ı ilerleyen yazılarda parçalara ayırmak. Ama önce ona dokunduğumuzda bir şeyi bozup bozmadığımızı söyleyecek bir güvenlik ağı lazım. İki soru: Bu method için yazacağın ilk test neyi doğrulamalı? Ve bu çıktıda, bugün yeşil olan bir testi yarın ya da başka bir makinede kırmızıya çevirebilecek kaç şey görüyorsun?

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

Michael Feathers, 2004 tarihli Working Effectively with Legacy Code kitabında bu tür testlere characterization test adını veriyor: kodun ne yapması gerektiğini değil, bugün gerçekte ne yaptığını kayda alan testler. Amaç doğruluğu kanıtlamak değil, değişimi fark etmek. Kod bir hata içeriyorsa test o hatayı da sabitler; bu bilinçli bir tercih, çünkü refactoring sırasında istediğimiz tek şey davranışın değişmemesi.

Elle yazılan bir characterization testin en basit yolu, beklenen değeri bilmeden yazıp testin sana söylemesine izin vermek. Önce boş bir beklentiyle başlıyoruz:

[Fact]
public void Total_line_of_a_domestic_order()
{
    string text = new InvoiceRenderer().Render(Samples.DomesticOrder());
    string totalLine = text.Split(Environment.NewLine).Single(l => l.StartsWith("Total"));

    Assert.Equal("", totalLine);
}
Assert.Equal() Failure: Strings differ
Expected: ""
Actual:   "Total                                        1,007"···
           ↑ (pos 0)

Hata mesajı gerçek değeri veriyor; onu beklentiye yapıştırınca test yeşile döner ve artık toplam satırındaki her değişiklik yakalanır. Bu yöntem tek bir değer için iyi, ama fatura 20 satırlık bir metin ve her satırı ayrı bir assertion'la sabitlemek hem yorucu hem de satırlar arası ilişkiyi (hangi satırın var, hangisinin yok olduğunu) kaçırır.

Approval Testing: Bütün Çıktıyı Bir Kez Onaylamak

Approval testing (ya da golden master yaklaşımı) aynı fikri ölçekliyor: çıktının tamamını bir dosyaya kaydet, bir insan bir kez bakıp onaylasın, sonraki her koşuda yeni çıktıyı onaylı dosyayla karşılaştır. .NET'te bunun yerleşik aracı Verify. Kurulumu ve snapshot review akışı Unit Test serisinin "Snapshot ve Property-based Testing: Verify ve FsCheck ile Unit Test'in Sınırlarını Genişletmek" yazısında anlatıldı; o yazıda 33.x sürümlerindeki bakım ücreti kontrolü nedeniyle 32.0.0 tercih edilmişti, burada da aynı sürümü kullanıyoruz.

[Fact]
public Task Domestic_order_invoice()
{
    string text = new InvoiceRenderer().Render(Samples.DomesticOrder());
    return Verify(text);
}

İlk koşuda karşılaştıracak bir dosya olmadığı için test kırmızı olur ve Verify bir .received.txt dosyası bırakır:

VerifyException : Directory: tests/Bookstore.Invoicing.Tests
New:
  - Received: InvoiceApprovalTests.Domestic_order_invoice.received.txt
    Verified: InvoiceApprovalTests.Domestic_order_invoice.verified.txt

BOOKSTORE INVOICE
Invoice no: INV-20261114-000042
Date: 11/14/2026
Customer: Ada Lovelace <ada@example.com>
----------------------------------------------------
Dune                        1 x   450.00      450.00
Emma                        2 x   180.00      360.00
  + gift wrap                                    9.80
Sapiens: A Brief Hist...    1 x   300.00      300.00
----------------------------------------------------
Subtotal                                     1,119.80
Discount (WELCOME10)                          -111.98
Shipping                                         FREE
Total                                        1,007.82
  incl. tax                                    141.00
Categories: Science Fiction, Classics, History
Please pay by bank transfer until 11/21/2026.
Reference: 000042

Dosyayı okuyup "evet, bugünkü fatura bu" dediğinde onu .verified.txt olarak kaydedersin ve repo'ya commit edersin. .received.txt dosyaları .gitignore'a girer. Test artık yeşil. Ama açılıştaki ikinci soru hâlâ cevapsız.

Cevap: Bugün Yeşil, Yarın Kırmızı

Aynı testi, sistem saat dilimini UTC'ye (saat dilimi farkı sıfır olan evrensel saat) çekerek tekrar çalıştırdım. Benim saatimle tarih 14 Kasım'dı, UTC'de hâlâ 13 Kasım:

$ TZ=UTC dotnet test
NotEqual:
  - Received: InvoiceApprovalTests.Domestic_order_invoice.received.txt
    Verified: InvoiceApprovalTests.Domestic_order_invoice.verified.txt

Received: InvoiceApprovalTests.Domestic_order_invoice.received.txt
BOOKSTORE INVOICE
Invoice no: INV-20261113-000042
Date: 11/13/2026
...
Please pay by bank transfer until 11/20/2026.

CI sunucun UTC'de çalışıyorsa bu test her gece belirli saatlerde kırılır, ertesi gün de zaten her yerde kırılır. İkinci sorun culture. Aynı testi Türkçe bölge ayarlı bir makinede çalıştırınca sayılar ve tarih başka biçimde yazılıyor:

Date: 14.11.2026
...
Subtotal                                     1.119,80
Discount (WELCOME10)                          -111,98
Total                                        1.007,82

Yani açılıştaki çıktıda en az üç kırılgan nokta vardı: fatura numarasındaki tarih, fatura tarihi ve havale son ödeme tarihi; bir de sayıların ve tarihlerin culture'a bağlı biçimi. Çözüm iki katmanlı. Culture'ı test projesinde sabitliyoruz; xUnit.net v3'te bunun için xunit.runner.json dosyası yeterli:

{
  "culture": "en-US"
}

Tarihleri ise üretim koduna dokunmadan, karşılaştırmadan önce çıktıdan temizliyoruz. Verify'ın scrubber'ları bu iş için var:

return Verify(text)
    .ScrubInlineDates("yyyyMMdd", CultureInfo.InvariantCulture)
    .ScrubInlineDates("M/d/yyyy", CultureInfo.InvariantCulture);
Invoice no: INV-Date_1-000042
Date: Date_1
...
Please pay by bank transfer until Date_2.

Verify aynı tarihe aynı numarayı veriyor: fatura numarasındaki tarih ile fatura tarihi aynı gün olduğu için ikisi de Date_1, son ödeme tarihi başka bir gün olduğu için Date_2. Böylece "iki tarih aynı mı" bilgisi korunuyor ama "son ödeme tarihi tam yedi gün sonra mı" bilgisi kayboluyor. Bu bir taviz. Kalıcı çözüm, saati koda dışarıdan vermek; beşinci yazıda DateTime.Now'ı TimeProvider'a taşıyınca bu scrubber'lar gereksizleşecek. Şimdilik amaç, koda dokunmadan ağı kurmak. Artık hem UTC'de hem Türkçe culture'da hem de Los Angeles saat diliminde test yeşil.

Deterministik olmayan çıktının başka kaynakları da var: ORDER BY'sız bir SQL sorgusundan gelen satırlar, Directory.EnumerateFiles'ın dosya sırası, paralel işlenen bir koleksiyon, rastgele üretilen Guid'ler. Her birinde soru aynı: bu fark gerçek bir davranış değişikliği mi, yoksa ölçümün gürültüsü mü? Gürültüyse scrub edilir ya da sıralanır; davranışsa snapshot'ta kalır.

Kombinasyonlarla Girdi Uzayını Taramak

Tek bir fatura, tek bir yolu sabitler. InvoiceRenderer'ın davranışı ise ülkeye, kupona, adede ve ödeme yöntemine göre dallanıyor. Her kombinasyon için ayrı test yazmak yerine Verify'ın combination desteğiyle girdilerin kartezyen çarpımını tek snapshot'ta topluyoruz:

[Fact]
public Task Totals_for_country_coupon_quantity_and_payment() =>
    Combination()
        .Verify(
            RenderTotals,
            ["TR", "DE", "US"],
            [null, "WELCOME10", "FIX40", "BOGUS"],
            [1, 3],
            ["card", "transfer"])
        .ScrubInlineDates("M/d/yyyy", CultureInfo.InvariantCulture);

RenderTotals her kombinasyon için bir sipariş kuruyor, faturayı üretiyor ve yalnız kupon mesajıyla toplam bloğunu tek satıra indiriyor; kalem satırları ana snapshot'ta zaten onaylı. 3 × 4 × 2 × 2 = 48 satırlık onaylı dosyadan birkaç satır:

TR, FIX40    , 3, card    : Subtotal 540.00 | Discount (FIX40) -40.00 | Shipping FREE | Total 500.00 | incl. tax 54.00 | Paid by card.,
TR, BOGUS    , 3, card    : Coupon BOGUS is not valid and was ignored. | ---------------------------------------------------- | Subtotal 540.00 | Shipping FREE | Total 540.00 | incl. tax 54.00 | Paid by card.,

Girdi değerlerini seçmek bir tasarım işi. Her parametre için "normal" bir değer, sınır değeri ve geçersiz bir değer koymak iyi bir başlangıç. Burada FIX40 ile üç adet Emma'nın birleşimi tam 500,00'lık bir net tutar üretiyor; bu, ücretsiz kargo eşiğinin tam üstü. Bu değeri nasıl bulduğumu ve neden gerektiğini aşağıdaki mutasyon bölümü gösterecek.

Kör Noktaları Bulmak: Coverage

Ağı kurduk ama nerelerinin delik olduğunu bilmiyoruz. İlk ölçü coverage. Test Stratejisi serisinin "Code Coverage Ne Söyler, Ne Söylemez: Line, Branch Coverage ve Mutation Testing" yazısındaki Microsoft Testing Platform (MTP; .NET'in yeni test çalıştırma altyapısı) extension'ını test projesine ekleyip coverage'ı Cobertura biçiminde alıyoruz:

dotnet test --coverage --coverage-output-format cobertura --coverage-output coverage.cobertura.xml

Rapordaki sayılar tek başına az şey söylüyor; bize hangi satırların hiç çalışmadığı lazım. Bunu .NET 10'un dosya tabanlı uygulama desteğiyle, proje dosyası olmayan küçük bir script'le çıkarıyoruz:

using System.Xml.Linq;

// Kullanım: dotnet run tools/uncovered.cs -- <cobertura.xml> <DosyaAdı.cs>
string reportPath = args[0];
string fileName = args[1];

XDocument report = XDocument.Load(reportPath);
XElement file = report.Descendants("class")
    .First(c => ((string?)c.Attribute("filename"))?.EndsWith(fileName) == true);
string[] source = File.ReadAllLines((string)file.Attribute("filename")!);

foreach (XElement line in file.Descendants("line").DistinctBy(l => (int)l.Attribute("number")!))
{
    int number = (int)line.Attribute("number")!;
    int hits = (int)line.Attribute("hits")!;
    string? branches = (string?)line.Attribute("condition-coverage");
    bool partial = branches is not null && !branches.StartsWith("100%");
    if (hits == 0 || partial)
    {
        string label = hits == 0 ? "never run" : $"branches {branches}";
        Console.WriteLine($"{number,4} {label,-22} {source[number - 1].Trim()}");
    }
}

İlk faturayla ve ilk kombinasyon setiyle (o zaman yalnız ülke, kupon ve ödeme yöntemi vardı) InvoiceRenderer'ın line coverage'ı yüzde 86,7, branch coverage'ı yüzde 88 çıktı. Script'in listesi:

  15 branches 50% (1/2)     if (order.Customer.IsVip)
  16 never run              sb.AppendLine("VIP customer - thank you for your loyalty!");
  19 branches 50% (1/2)     if (order.Items.Count == 0)
  20 never run              {
  21 never run              sb.AppendLine("No items.");
  22 never run              return sb.ToString();
  46 branches 50% (1/2)     if (item.Book.Format != BookFormat.Print)
  47 never run              {
  48 never run              sb.AppendLine("  (gift wrap not available for digital items)");
  49 never run              }
  67 branches 50% (1/2)     if (discount > subtotal)
  68 never run              discount = subtotal;
  97 branches 50% (1/2)     else if (order.PaymentMethod == "transfer")
 103 never run              {
 104 never run              sb.AppendLine("Payment method: " + order.PaymentMethod);
 105 never run              }
 107 branches 50% (1/2)     if (physical == 0)
 108 never run              sb.AppendLine("Your download links have been sent to " + order.Customer.Email + ".");

Liste bir alışveriş listesi gibi okunuyor: VIP (özel statülü) müşteri, boş sipariş, dijital ürüne hediye paketi, sipariş tutarını aşan kupon, kart ve havale dışında bir ödeme yöntemi, yalnız dijital ürün içeren sipariş. Bunların çoğunu iki siparişle karşılıyoruz: e-kitap ve sesli kitaptan oluşan, hediye paketi istenmiş, 900'lük sabit kuponlu ve "voucher" ile ödenen bir VIP siparişi, bir de boş sipariş. İki yeni snapshot'tan sonra line ve branch coverage yüzde 100.

Testin Kendisini Sınamak: Bilinçli Mutasyonlar

Yüzde 100 coverage, her satırın en az bir kez çalıştığını söyler; çalışan satırın sonucunun bir assertion tarafından kontrol edildiğini söylemez. Bunu görmek için koda bilerek küçük hatalar yerleştirdim ve her birinden sonra testleri çalıştırdım. Bu, mutation testing'in elle yapılmış hâli; Stryker.NET aynı işi otomatik yapıyor ve Test Stratejisi yazısında anlatıldı.

MutasyonSonuçYakalayan test
Kuponun sipariş tutarıyla sınırlanmasını kaldırYakalandıDigital_vip_order_invoice
Geçersiz kupon mesajını yazmaYaşadıYok
Ücretsiz kargo koşulunu >= 500 yerine > 500 yapYaşadıYok
E-kitap vergisini 0,20 yerine 0,25 yapYakalandıİki fatura snapshot'ı
Avrupa Birliği kargosunda ek kitap ücretini physical - 1 yerine physical ile hesaplaYakalandıKombinasyon testi

Geçersiz kupon mesajının satırı coverage raporunda çalışmış görünüyordu: BOGUS kombinasyonları onu çalıştırıyordu. Ama RenderTotals o sırada çıktıyı Subtotal satırından itibaren alıyordu ve mesaj ondan önce geliyordu; çalışan satırın çıktısına kimse bakmıyordu. Sınır değeri mutasyonu ise hiçbir girdi tam 500'e denk gelmediği için yaşadı. Kombinasyon testini bu iki bulguya göre değiştirdim: kupon mesajını da dahil ettim, adet boyutunu ve 540 − 40 = 500 eden FIX40 kuponunu ekledim. İki mutasyonu tekrar uyguladığımda ikisi de kombinasyon testinde kırmızı:

failed Bookstore.Invoicing.Tests.InvoiceCombinationTests.Totals_for_country_coupon_quantity_and_payment
Test run summary: Failed!

Birkaç bilinçli mutasyon, Stryker.NET'i kurmadan önce bile ağın kalitesi hakkında çok şey söylüyor. Özellikle değiştirmeyi planladığın bölgede, örneğin kargo hesabını ayıracaksan kargo kodunda, iki üç mutasyon denemek ucuz bir sigorta.

İskeleti Sökmek: Characterization Testten Davranış Testine

Characterization testler bir inşaat iskelesi gibi: yapıyı değiştirirken seni korurlar ama kalıcı olmaları için tasarlanmamışlardır. Bir snapshot "fatura bugün böyle görünüyor" der, "ücretsiz kargo 500'den başlar" demez. Kırmızıya döndüğünde neyin neden değiştiğini anlamak için diff'i okuman gerekir.

Ağ hazır olduğunda refactoring başlayabilir. İlk adım olarak kargo hesabını ShippingFees adında ayrı bir class'a çıkardım; bu commit yalnız üretim kodunu değiştiriyor ve beş snapshot testi yeşil kalıyor. Hemen ardından, ayrı bir commit'te, artık doğrudan çağrılabilen kuralı adı olan testlerle tarif ediyoruz:

[Theory]
[InlineData(500.00, 0)]
[InlineData(499.99, 39.90)]
public void Domestic_shipping_is_free_from_500(decimal netAmount, decimal expected) =>
    Assert.Equal(expected, ShippingFees.For("TR", printedBooks: 1, netAmount));

[Fact]
public void Eu_shipping_adds_two_per_extra_printed_book() =>
    Assert.Equal(16.50m, ShippingFees.For("DE", printedBooks: 3, netAmount: 100m));
$ git log --oneline -2
354ca18 test: describe shipping rules with behavior tests
4af1750 refactor: extract ShippingFees from InvoiceRenderer

Refactoring ilerledikçe her kural böyle kendi davranış testine kavuşur ve snapshot'ların taşıdığı yük azalır. Sonunda faturanın biçimini (hizalama, satır sırası) koruyan bir iki snapshot kalır; hesaplama kurallarını snapshot'lar değil, adı olan testler anlatır. Kombinasyon testi gibi geniş snapshot'ları refactoring bittiğinde silmek de meşru bir karardır.

Sık Düşülen Tuzaklar

Deterministik olmayan çıktıyı onaylamak. Tarih, saat dilimi, culture, Guid ya da sıralaması garanti olmayan koleksiyonlar snapshot'a girdiyse test bir gün kendiliğinden kırılır. Ekip bir süre sonra kırmızı snapshot'ları düşünmeden onaylamaya başlar ve ağ işlevini yitirir. İlk onaydan önce testi farklı bir saat diliminde ve culture'da bir kez çalıştırmak bu sorunların çoğunu yakalar.

Hatalı davranışı "doğru" diye sabitlediğini unutmak. Dijital VIP siparişinin onaylı faturasında vergi satırı 82.00. Bookstore'un kuralına göre sesli kitaplar da dijital ürün ve yüzde 20 vergiye tabi; doğru değer 104,00 olmalıydı, çünkü kod yalnız Ebook'u kontrol ediyor. Üstelik toplam 0,00 iken 82,00 vergi yazıyor. Characterization testin görevi bu hatayı korumak; ama hatayı bir yere not etmezsen birkaç ay sonra snapshot "doğru davranış" gibi okunur. Bulduğun tuhaflıkları bir issue'ya yaz ve düzeltmeyi refactoring'den ayrı, kendi commit'inde, snapshot'ı bilinçli olarak güncelleyerek yap.

.received dosyasını okumadan onaylamak. Verify'ın bir diff aracıyla gelen "accept all" kolaylığı, yanlış bir çıktıyı da bir tıkla onaylar. Onay bir review işidir: her satıra bak, emin olmadığın satır varsa üretim koduna git.

Çok geniş ya da çok dar snapshot. Bütün sipariş object'ini JSON olarak onaylamak, alakasız bir property eklendiğinde bile testleri kırar. Yalnız toplam satırını onaylamak ise kupon mesajı gibi davranışları kaçırır. Snapshot, korumak istediğin davranışın sınırını çizmeli.

Coverage'ı hedef sanmak. Yüzde 100 coverage'a ulaştığımızda iki mutasyon hâlâ yaşıyordu. Coverage hangi kodun çalışmadığını gösteren bir araç; hangi kodun korunduğunu göstermez.

Küçük Bir Deneme

★☆☆ Kendi projende testi olmayan, metin ya da JSON üreten bir method seç. Önce Assert.Equal("", ...) yöntemiyle tek bir satırını sabitle, sonra aynı çıktıyı Verify ile bütün olarak onayla. İkisinin bakım maliyetini karşılaştır.

★★☆ Onayladığın snapshot testini TZ=UTC ve Türkçe culture ile, Windows'ta da PowerShell'de Set-TimeZone ve Set-Culture ile değiştirdiğin bir oturumda tekrar çalıştır. Kaç farklı kırılma noktası buldun ve her biri için scrub mı, sabitleme mi, yoksa üretim kodunda bir seam mi seçtin?

★★★ Bu yazıdaki mutasyon tablosunu kendi kodunda üret: değiştirmeyi planladığın bölgede beş mutasyon yap ve her birinden sonra testleri çalıştır. Yaşayan mutasyonları öldürecek en küçük girdi değişikliği ne? Sonra aynı bölgede Stryker.NET'i çalıştırıp elle bulduklarınla karşılaştır.

Güvenlik ağı hazır. Bir sonraki yazıda PricingEngine.Calculate'i IDE'nin otomatik refactoring'leriyle adım adım açacağız: her adımdan sonra derleyip test etmek, imza değişikliklerinde parallel change ile çağıranları yavaş yavaş taşımak ve otomatik araçların güvenli olmadığı yerleri tanımak.