Mert Özen Her Satırda Daha İleri
Yazılım Tasarımı Clean Code — #10

Temiz Test Kodu: F.I.R.S.T., Okunabilir Assert ve Test Data Builder

Mert Özen 7 Kas 2026 11 dk 2 görüntülenme
Temiz Test Kodu: F.I.R.S.T., Okunabilir Assert ve Test Data Builder

Temiz test hızlı, bağımsız, her gün aynı sonucu veren, kendini doğrulayan ve zamanında yazılmış testtir (F.I.R.S.T.); legacy OrderService testleri DateTime.Now yüzünden cumartesi, static Db yüzünden sıraya göre kırılıyor. TimeProvider ve FakeTimeProvider, test data builder, cümle gibi okunan test adları ve kırıldığında sebebi söyleyen assert'lerle aynı kuralları temiz testlere çeviriyoruz.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.

Önce Tahmin Et: Perşembe Günü Yazılan Test

Legacy OrderService için ekipten biri iki test yazmış. Testleri bir perşembe yazmış, ikisi de yeşilmiş, commit'leyip gitmiş:

public class OrderServiceTests
{
    [Fact]
    public void Test1()
    {
        var s = new OrderService();
        var d = new Dictionary<string, int> { ["978-0441013593"] = 1, ["978-0553380163"] = 1 };
        int id = s.ProcessData(1, d, null, false, false, 1);
        Assert.True(id > 0);
        var o = s.GetOrder(id);
        Assert.Equal(705.38m, o.Total);
        Assert.Equal(9, Db.Books[0].Stock);
    }

    [Fact]
    public void Test2()
    {
        var s = new OrderService();
        var d = new Dictionary<string, int> { ["978-0441013593"] = 2 };
        int id = s.ProcessData(2, d, null, false, false, 1);
        Assert.Equal(8, Db.Books[0].Stock);
    }
}

Bu yazıyı yazdığım gün cumartesi. Testler bugün CI'da (Continuous Integration) koşunca hangisi geçer, hangisi kırılır? Kırılanın mesajı sana sebebi söyler mi?

Test Kodu da Production Kodudur

  Failed Bookstore.Tests.Legacy.OrderServiceTests.Test1 [1 ms]
  Error Message:
   Assert.Equal() Failure: Values differ
Expected: 705.38
Actual:   690.52

Perşembe günü Ada'nın siparişi 705,38 TRY (Türk lirası) tutuyordu: 675 TRY ara toplam, VIP (Very Important Person) indirimi 33,75, %10 vergi. Cumartesi ProcessData içindeki DateTime.Now hafta sonu indirimini de devreye soktu ve toplam 690,52 oldu. Kodda hiçbir şey değişmedi; değişen takvim. Test mesajı yalnız iki sayı veriyor; farkın nedenini bulmak için ProcessData'nın yaklaşık 135 satırını okuman gerekiyor.

Hikâyenin devamı daha öğretici. Pazartesi biri testi "düzeltip" beklenen değeri 690,52 yapıyor. Bu kez suite şunu söylüyor:

  Failed Bookstore.Tests.Legacy.OrderServiceTests.Test1 [1 ms]
  Error Message:
   Assert.Equal() Failure: Values differ
Expected: 9
Actual:   7

Ama testi tek başına çalıştırınca geçiyor:

Passed!  - Failed:     0, Passed:     1, Skipped:     0, Total:     1, Duration: 7 ms - t10.dll (net10.0)

Sebep static Db. Test2 önce koştu, Dune'un stoğunu 10'dan 8'e düşürdü; Test1 aynı listeyi gördü ve stok 7'ye indi. İki test birbirinin state'ini kullanıyor ve sonuç sıraya bağlı.

Robert C. Martin (Uncle Bob) Clean Code kitabının (2008; ikinci baskısı 2025) unit test bölümünde, kirli test kodunun hiç test olmamasından daha kötü olabileceğini anlatır: kod değiştikçe testler de değişmek zorundadır, okunmayan ve rastgele kırılan testler bakımı zorlaştırır, ekip sonunda onları siler ya da Skip'ler. Yukarıdaki iki test tam o yolun başında. İsimleri bir şey anlatmıyor, kurulumları gerçek global state'e bağlı, assert'leri sebebi değil yalnız sonucu söylüyor. Test kodu, production koduyla aynı özeni hak ediyor; farklı olan tek şey öncelikler.

F.I.R.S.T.: Temiz Testin Beş Özelliği

Clean Code'un unit test bölümü temiz testi F.I.R.S.T. (Fast, Independent, Repeatable, Self-Validating, Timely) kısaltmasında toplar. Legacy testler beşinden üçünü ihlal ediyor:

  • Fast (hızlı): Test saniyeler değil milisaniyeler sürmeli ki her değişiklikte koşulsun. Legacy testler hızlı, çünkü Db bellekte; gerçek bir veritabanına gitselerdi bu da kırılırdı.
  • Independent (bağımsız): Testler birbirine state bırakmamalı, her sırada aynı sonucu vermeli. Static Db yüzünden Test1'in sonucu Test2'nin önce koşup koşmamasına bağlı.
  • Repeatable (tekrarlanabilir): Her ortamda, her gün aynı sonuç. DateTime.Now yüzünden test hafta içi geçiyor, hafta sonu kırılıyor.
  • Self-Validating (kendini doğrulayan): Sonuç yalnız geçti ya da kaldı olmalı; log okuyup karar vermek gerekmemeli. Bu konuda legacy testler idare ediyor.
  • Timely (zamanında): Testler production kodundan hemen önce ya da onunla birlikte yazılmalı; sonradan yazılan test, test edilmesi zor bir tasarıma çarpar. OrderService'in test edilememesinin asıl nedeni bu.

Bundan sonraki her şey, dokuzuncu yazıda OrderService'ten ayırdığımız OrderPricing class'ı üzerinde bu beş özelliği sağlamakla ilgili.

Test Başına Tek Kavram ve İsimlendirme

Test1 aynı anda sipariş numarasını, toplamı ve stok düşmeyi doğruluyor. İlk assert kırılınca diğer ikisi hiç çalışmıyor, ve test adı hangi kavramın bozulduğunu söylemiyor. Martin'in önerisi test başına tek kavram; birden fazla assert olabilir, yeter ki aynı davranışı anlatsınlar.

İsimlendirmede .NET dünyasında iki yaygın stil var. Birincisi Roy Osherove'un (The Art of Unit Testing kitabının yazarı) 2005'te önerdiği MethodName_StateUnderTest_ExpectedBehavior pattern'i: Quote_VipCustomer_AppliesFivePercentDiscount. İkincisi davranışı cümle olarak yazmak: Vip_customer_gets_five_percent_off_the_subtotal. İkisi de Test1'den çok daha iyi; ekip içinde tutarlı olmak hangisini seçtiğinden daha önemli. Cümle stili, test listesinin bir spesifikasyon gibi okunmasını sağlıyor:

  Passed Bookstore.Tests.OrderPricingTests.Audiobook_is_priced_at_ninety_percent_of_list_price [3 ms]
  Passed Bookstore.Tests.OrderPricingTests.Standard_shipping_below_500_depends_on_the_city(city: "Ankara", expectedFee: 39.9) [< 1 ms]
  Passed Bookstore.Tests.OrderPricingTests.Standard_shipping_below_500_depends_on_the_city(city: "Istanbul", expectedFee: 29.9) [< 1 ms]
  Passed Bookstore.Tests.OrderNotifierTests.Confirmation_goes_to_the_customer_and_states_the_total [5 ms]
  Passed Bookstore.Tests.OrderPricingTests.Regular_customer_on_a_weekday_pays_list_price_plus_tax_and_shipping [1 ms]
  Passed Bookstore.Tests.OrderPricingTests.Vip_customer_gets_five_percent_off_the_subtotal [< 1 ms]
  Passed Bookstore.Tests.OrderPricingTests.Weekend_order_gets_an_extra_two_percent_off [< 1 ms]
  Passed Bookstore.Tests.OrderPricingTests.Unknown_coupon_is_rejected [< 1 ms]

Bu listeyi pazarlamadan biri de okuyabilir. Toplam süre 25 milisaniye; F.I.R.S.T.'ün ilk harfi sağlandı.

Arrange Gürültüsünü Azaltmak: Test Data Builder ve Object Mother

OrderPricing.Quote beş parametre ve bir saat istiyor. Her test hepsini elle kurarsa, VIP indirimini test eden satır kargo şehri ve kupon gürültüsünün içinde kaybolur. İki bilinen çözüm var. Object Mother (ThoughtWorks'te adı konan ve Martin Fowler'ın bliki'sinde anlatılan pattern) hazır, adı olan test object'leri üreten bir fabrika. Test Data Builder ise Nat Pryce'ın (Growing Object-Oriented Software, Guided by Tests kitabının iki yazarından biri) 2007'deki yazısıyla yayıldı: her alanın makul bir varsayılanı vardır, test yalnız önemsediği değeri değiştirir.

// Object Mother: adı olan, hazır test kitapları
public static class TestBooks
{
    public static Book Dune() => new() { Isbn = "978-0441013593", Title = "Dune", Price = 450m };

    public static Book Audiobook(decimal price = 250m) =>
        new() { Isbn = "978-0553380163", Title = "A Brief History of Time", Price = price, Format = BookFormat.Audio };
}

// Test Data Builder: yalnız testin önemsediği değer yazılır, gerisi makul varsayılan
public sealed class QuoteBuilder
{
    private static readonly TimeZoneInfo Istanbul = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul");

    private readonly List<CartLine> _lines = [];
    private CustomerTier _tier = CustomerTier.Regular;
    private string? _coupon;
    private ShippingMethod _shipping = ShippingMethod.Standard;
    private string _city = "Ankara";
    private DateTimeOffset _now = new(2026, 11, 5, 10, 0, 0, TimeSpan.FromHours(3));   // perşembe

    public QuoteBuilder With(Book book, int quantity = 1)
    {
        _lines.Add(new CartLine(book, quantity));
        return this;
    }

    public QuoteBuilder ForVip()
    {
        _tier = CustomerTier.Vip;
        return this;
    }

    public QuoteBuilder WithCoupon(string code)
    {
        _coupon = code;
        return this;
    }

    public QuoteBuilder ShippedTo(string city)
    {
        _city = city;
        return this;
    }

    public QuoteBuilder On(DateTimeOffset now)
    {
        _now = now;
        return this;
    }

    public PriceQuote Quote()
    {
        var clock = new FakeTimeProvider(_now);
        clock.SetLocalTimeZone(Istanbul);
        IReadOnlyList<CartLine> lines = _lines.Count > 0 ? _lines : [new CartLine(TestBooks.Dune(), 1)];
        return new OrderPricing(clock).Quote(lines, _tier, _coupon, _shipping, _city);
    }
}

Testler artık tek satırlık bir arrange ile davranışı anlatıyor:

[Fact]
public void Vip_customer_gets_five_percent_off_the_subtotal()
{
    PriceQuote quote = new QuoteBuilder().ForVip().Quote();

    Assert.Equal(22.50m, quote.Discount);
}

[Fact]
public void Weekend_order_gets_an_extra_two_percent_off()
{
    PriceQuote quote = new QuoteBuilder().On(Saturday).Quote();

    Assert.Equal(9m, quote.Discount);
}

[Theory]
[InlineData("Istanbul", 29.90)]
[InlineData("Ankara", 39.90)]
public void Standard_shipping_below_500_depends_on_the_city(string city, decimal expectedFee)
{
    PriceQuote quote = new QuoteBuilder().ShippedTo(city).Quote();

    Assert.Equal(expectedFee, quote.Shipping);
}

Hafta sonu testi artık her gün aynı sonucu veriyor, çünkü saat FakeTimeProvider'dan geliyor. Builder'ın varsayılanlarını bilerek seçtim: perşembe, normal müşteri, Ankara. Böylece her test yalnız bir kuralı devreye sokuyor ve Discount'taki 9 TRY'nin tek açıklaması hafta sonu. Builder'ın bir başka faydası da değişime dayanıklılık: Quote'a yarın altıncı bir parametre eklenirse yalnız builder değişir, kırk test değil.

Okunabilir Assert: Başarısız Olunca Ne Söylüyor?

Assert'in kalitesi test geçerken değil kırılınca anlaşılır. VIP testinde beklenen indirimi bilerek yanlış (25 TRY) yazıp üç farklı assert ile çalıştırdım. Assert.True(quote.Discount == 25m):

   Assert.True() Failure
Expected: True
Actual:   False

Assert.Equal(25m, quote.Discount):

   Assert.Equal() Failure: Values differ
Expected: 25
Actual:   22.50

Bütün record ile Assert.Equal(new PriceQuote(...), quote):

   Assert.Equal() Failure: Values differ
Expected: PriceQuote { Subtotal = 450, Discount = 25, Shipping = 39.90, Tax = 42.50, Total = 507.40 }
Actual:   PriceQuote { Subtotal = 450, Discount = 22.50, Shipping = 39.90, Tax = 42.7500, Total = 510.15 }

İlki hiçbir şey söylemiyor; CI log'unda bunu gören kişi debugger açmak zorunda. İkincisi değeri veriyor, üçüncüsü bütün hesabı. Record'ların value equality'si ve okunur ToString()'i burada bedava bir assert kütüphanesi gibi çalışıyor. Tek bir toplamı doğrulayan ama kırıldığında hesabı açıklayan domain-specific bir helper da birkaç satır:

// Domain diliyle konuşan assert: başarısız olunca hesabın dökümünü verir
public static class PriceAssert
{
    public static void TotalIs(decimal expected, PriceQuote quote)
    {
        if (quote.Total != expected)
            Assert.Fail(string.Create(CultureInfo.InvariantCulture,
                $"Expected total {expected:0.00} but was {quote.Total:0.00} " +
                $"(subtotal {quote.Subtotal:0.00}, discount {quote.Discount:0.00}, shipping {quote.Shipping:0.00}, tax {quote.Tax:0.00})."));
    }
}
   Expected total 507.40 but was 510.15 (subtotal 450.00, discount 22.50, shipping 39.90, tax 42.75).

Bu tür helper'ların ikinci getirisi sekizinci yazıdaki boundary fikri. Testlerin assert kütüphanesine doğrudan değil kendi helper'larına bağlı olması, FluentAssertions 8'in Ocak 2025'te ticari lisansa geçmesi gibi bir durumda geçişi küçültüyor; Shouldly ya da topluluk fork'u AwesomeAssertions'a geçmek yalnız helper'ları değiştirmek demek.

Gizli Bağımlılıklar: Saat, Static State ve Aşırı Mock

Legacy testleri bozan iki şey de görünmez bağımlılıklardı: DateTime.Now ve static Db. Çözüm ikisinde de aynı, bağımlılığı constructor'dan almak. Saat için .NET 8'den beri BCL'de (Base Class Library) TimeProvider var; production'da TimeProvider.System, testte Microsoft.Extensions.TimeProvider.Testing paketindeki FakeTimeProvider kullanılıyor. FakeTimeProvider zamanı ileri sarmayı (Advance) ve local time zone'u ayarlamayı da destekliyor; builder'daki SetLocalTimeZone çağrısı, hafta sonu kuralının UTC'ye (Coordinated Universal Time) göre değil İstanbul saatine göre değerlendirilmesini sağlıyor. Zaman ve time zone tasarımının ayrıntıları Çözüm Desenleri serisinin "Zaman ve Time Zone: UTC Instant, Yerel İş Günü, DateOnly ve TimeProvider" yazısında.

Bağımlılıkları dışarıdan almak bir tehlike de getiriyor: her şeyi mock'lamak. OrderNotifier için iki test:

// Kırılgan: e-posta metnindeki her boşluğu ve satır sonunu sabitliyor
[Fact]
public async Task SendConfirmationAsync_WhenCalled_CallsSendAsyncWithExactBody()
{
    var sender = new Mock<IEmailSender>();
    var notifier = new OrderNotifier(sender.Object);

    await notifier.SendConfirmationAsync(Order);

    sender.Verify(s => s.SendAsync(
        It.Is<EmailMessage>(m => m.Body == "Dear Ada Lovelace,\nYour order #1000 has been received.\n - Dune x1 = 450.00\nShipping: 39.90\nTotal: 534.90 TRY\n"),
        It.IsAny<CancellationToken>()), Times.Once);
}

// Davranışı test eder: kime, hangi konu, hangi toplam
[Fact]
public async Task Confirmation_goes_to_the_customer_and_states_the_total()
{
    var sender = new RecordingEmailSender();

    await new OrderNotifier(sender).SendConfirmationAsync(Order);

    EmailMessage mail = Assert.Single(sender.Sent);
    Assert.Equal("ada@example.com", mail.To);
    Assert.Equal("Order #1000", mail.Subject);
    Assert.Contains("Total: 534.90 TRY", mail.Body);
}

İkisi de bu makinede geçiyor. Ama ilki, metne bir boşluk eklendiğinde kırılıyor ve daha sinsisi Windows'ta da kırılacak: StringBuilder.AppendLine satır sonu olarak Environment.NewLine kullanır, o da Windows'ta \r\n'dir. Test davranışı değil, implementation'ın tam metnini sabitliyor. İkinci test elle yazılmış küçük bir fake (RecordingEmailSender) kullanıyor ve yalnız önemli olanı doğruluyor. Kural: mock'u dışarıyla etkileşimin kendisi önemliyse kullan (ödeme bir kez mi çekildi?), sonucu kontrol edebiliyorsan state'e bak. xUnit, Moq ve test double türlerinin teknik ayrıntısı C# İleri serisinin "C#'ta Unit Test: xUnit, Theory/InlineData ve Test Edilebilir Kod" ve ASP.NET Core Web API (Application Programming Interface) serisinin "ASP.NET Core Web API'de Unit Test: xUnit ve Moq ile Service ve Controller Testleri" yazılarında.

Sık Düşülen Tuzaklar

Test içinde if ya da döngü. Beş kargo şehrini bir foreach ile tek testte dolaşırsan, kırıldığında hangi şehrin bozulduğunu mesaj söylemez ve ilk hatada geri kalanlar hiç koşmaz. Her durum [InlineData] ile ayrı bir test olsun; yukarıdaki şehir testi böyle.

Private method'ları test etmek için reflection kullanmak ya da onları internal yapıp InternalsVisibleTo ile açmak. OrderPricing.UnitPrice private ve test edilmesine gerek yok; Audiobook_is_priced_at_ninety_percent_of_list_price onu public Quote üzerinden zaten doğruluyor. Private method'u ayrıca test etme ihtiyacı, çoğu zaman ayrı bir class'ın sinyalidir.

Paylaşılan, değiştirilebilir fixture. Legacy testlerin static Db problemi, bir test class'ında static bir builder ya da liste tutunca da yaşanır. xUnit her test için yeni bir class instance'ı yaratır; state'i instance field'larında tut.

Bütün çıktıyı snapshot'a bağlamak. Tüm JSON (JavaScript Object Notation) cevabını ya da e-posta metnini birebir karşılaştıran testler, yukarıdaki mock örneği gibi her biçim değişikliğinde kırılır ve ekip onları düşünmeden günceller. Snapshot gerçekten contract'ın kendisiyse, örneğin public bir API'nin cevabıysa anlamlı; değilse önemli alanları ayrı ayrı doğrula.

Ne Zaman Kullanma

İki alanlı bir record için builder yazma. new CartLine(TestBooks.Dune(), 2) zaten okunur; builder'ın getirisi, çok parametreli ve çoğu parametrenin testle ilgisiz olduğu kurulumlarda. Record'larda with expression'ı da hafif bir builder gibi çalışır: baseline with { Quantity = 3 }.

Her assert için domain-specific helper yazma. PriceAssert.TotalIs toplamın birçok testte doğrulandığı ve kırıldığında hesabın dökümünün gerçekten işe yaradığı için var. Tek bir testte kullanılacak helper, okuyanın bir dosya daha açmasına sebep olur.

Characterization test'lere bu yazıdaki kuralları baştan uygulamaya çalışma. Legacy kodu değiştirmeden önce davranışını sabitleyen testler, kodun kendisi kadar kirli olabilir; amaçları güvenlik ağı olmak. On dördüncü yazıda OrderService'i temizlerken önce bu tür testlerle başlayacağız ve onları adım adım bu yazıdaki biçime yaklaştıracağız.

Küçük Bir Deneme

★☆☆ Legacy Test1 ve Test2'yi F.I.R.S.T. açısından yeniden yaz: Db.Reset() ve Db.Seed() ile her testin kendi state'ini kurmasını sağla. DateTime.Now sorununu legacy kodu değiştirmeden çözebiliyor musun? Çözemiyorsan bu, kod hakkında ne söylüyor?

★★☆ QuoteBuilder'a WithLines(int count) ekle ve "beşten fazla satıra 15 TRY indirim" kuralı için sınır değerlerini (5 ve 6 satır) test et. Test adlarını cümle stiliyle yaz.

★★★ OrderNotifier'ın Moq ile yazılmış kırılgan testini, RecordingEmailSender'ı kullanan ve Environment.NewLine'dan bağımsız bir teste dönüştür; sonra aynı testi FluentAssertions 7, Shouldly ve düz xUnit assert'leriyle yaz ve kırıldıklarında verdikleri mesajları karşılaştır.

Bu yazıyla Blok B'nin class ve test tarafı tamamlandı. Bir sonraki yazı Kent Beck'in (Extreme Programming'in yaratıcısı ve Test-Driven Development'ın öncüsü) dört simple design kuralına bakıyor: testler geçer, niyet açıktır, tekrar yoktur, eleman sayısı en azdır. Sıralamanın neden önemli olduğunu küçük bir indirim özelliğini adım adım büyüterek göreceğiz.