Mert Özen Her Satırda Daha İleri
Test ve Kalite Unit Test — #10

Test Kodunu Temiz Tutmak: Test Data Builder, Object Mother, AutoFixture ve Bogus

Mert Özen 8 Kas 2026 8 dk 2 görüntülenme
Test Kodunu Temiz Tutmak: Test Data Builder, Object Mother, AutoFixture ve Bogus

Test verisinde kural basit: testin dayandığı değer testte açıkça yazılır, gerisi anlamlı varsayılanlara ya da anonim veriye bırakılır. Seed'siz Bogus verisiyle yazılmış bir kargo testi 30 koşunun 6'sında kaldı. Test Data Builder, Object Mother'ın büyüme sorunu, AutoFixture ve AutoData, AutoNSubstitute'un NSubstitute 6 ile NU1608 uyarısı, sabit seed'li Bogus ve test kodunda DRY ile DAMP dengesi.

Sürüm notu: Bu yazıdaki kodlar .NET 10, xUnit.net v3 4.0.2, AutoFixture 4.18.1, AutoFixture.AutoNSubstitute 4.18.1, AutoFixture.Xunit3 4.19.0, Bogus 35.6.5 ve NSubstitute 6.2.0 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un kargo kuralı: Türkiye içinde 50 EUR'nun altındaki siparişler 4,99 EUR kargo öder, 50 ve üzeri bedava, yurt dışına gönderim 12 EUR. Bir geliştirici "sabit veri testleri körleştirir" diyerek kargo testini Bogus ile rastgele veriyle yazmış:

namespace Bookstore.Ordering;

public static class ShippingPolicy
{
    public static decimal FeeFor(Order order) =>
        order.Customer.ShippingAddress.Country != "TR" ? 12.00m
        : order.Subtotal >= 50m ? 0m
        : 4.99m;
}
[Fact]
public void Domestic_order_below_free_shipping_threshold_pays_4_99()
{
    Faker faker = new();
    Customer customer = new("C-1", faker.Name.FullName(), faker.Internet.Email(),
        new Address(faker.Address.StreetAddress(), "Istanbul", "34000", "TR"), 0);
    Book book = new(faker.Commerce.Ean13(), faker.Commerce.ProductName(), faker.Random.Decimal(5m, 60m));
    Order order = new(Guid.NewGuid(), customer, [new OrderLine(book, 1)]);

    Assert.Equal(4.99m, ShippingPolicy.FeeFor(order));
}

Testi 30 kez çalıştırdım. Kaç kez kaldı? Kaldığında hata mesajı neyin yanlış olduğunu söylüyor mu?

Cevap: 30 Koşudan 6'sı Kırmızı

pass=24 fail=6

    Bookstore.Ordering.Tests.RandomDataTests.Domestic_order_below_free_shipping_threshold_pays_4_99 [FAIL]
      Assert.Equal() Failure: Values differ
      Expected: 4.99
      Actual:   0

Kitap fiyatı 5 ile 60 arasında rastgele; fiyat 50'nin üstüne çıktığında kargo bedava oluyor ve test kalıyor. Kod doğru, test yanlış: test adı "eşiğin altındaki sipariş" diyor ama veri bunu garanti etmiyor. Hata mesajı da fiyatı göstermiyor; kırmızıyı CI'da (Continuous Integration) gören biri onu yeniden üretemez. Rastgele veri bir araç; yanlış kullanıldığında flaky test üretir. Bu yazıda test verisini kurmanın dört yolunu, ne zaman hangisinin işe yaradığıyla birlikte ele alıyoruz.

Test Data Builder: Anlamlı Varsayılanlar

Nat Pryce'ın 2007'de yazdığı ve GOOS (Growing Object-Oriented Software, Guided by Tests) kitabında da anlatılan Test Data Builder, geçerli bir object'i anlamlı varsayılanlarla kuran ve testin yalnız önemsediği alanı değiştirmesine izin veren küçük bir class:

// Test Data Builder: anlamlı varsayılanlar, test yalnız önemsediğini değiştirir
public sealed class OrderBuilder
{
    private string country = "TR";
    private int completedOrders;
    private readonly List<OrderLine> lines = [];

    public static OrderBuilder AnOrder() => new();

    public OrderBuilder ShippedTo(string countryCode)
    {
        country = countryCode;
        return this;
    }

    public OrderBuilder ForLoyalCustomer()
    {
        completedOrders = 7;
        return this;
    }

    public OrderBuilder WithBook(decimal price, int quantity = 1)
    {
        lines.Add(new OrderLine(new Book($"978-{lines.Count:D10}", "Any Book", price), quantity));
        return this;
    }

    public Order Build()
    {
        Customer customer = new("C-1", "Ada", "ada@example.com",
            new Address("1 Main St", "Istanbul", "34000", country), completedOrders);
        IReadOnlyList<OrderLine> orderLines = lines.Count > 0 ? lines : [new OrderLine(new Book("978-0000000000", "Any Book", 10m), 1)];
        return new Order(Guid.NewGuid(), customer, orderLines);
    }
}
public sealed class ShippingPolicyTests
{
    [Fact]
    public void Domestic_order_below_50_pays_4_99() =>
        Assert.Equal(4.99m, ShippingPolicy.FeeFor(AnOrder().WithBook(49.99m).Build()));

    [Fact]
    public void Domestic_order_of_50_ships_free() =>
        Assert.Equal(0m, ShippingPolicy.FeeFor(AnOrder().WithBook(25m, quantity: 2).Build()));

    [Fact]
    public void International_order_pays_12_regardless_of_amount() =>
        Assert.Equal(12.00m, ShippingPolicy.FeeFor(AnOrder().ShippedTo("DE").WithBook(200m).Build()));
}

Her test tek satır ve yalnız kargo kuralının baktığı iki şeyi söylüyor: ülke ve tutar. Müşterinin adı, e-postası, sokak adresi builder'ın varsayılanlarında saklı; test okura "bunlar önemsiz" diyor. Customer'a yarın yeni bir zorunlu alan eklendiğinde yalnız builder değişir, testler değişmez. 03. yazıdaki "minimal girdi" kuralının ölçeklenebilir hâli bu.

İyi bir builder'ın iki özelliği var. Varsayılanlar geçerli bir object üretir: AnOrder().Build() tek başına çalışır, varsayılan satırı da var. Method adları alan dilinden gelir: ForLoyalCustomer(), WithCompletedOrders(7)'den fazlasını söylüyor; sadakatin kaç siparişle başladığını builder biliyor ve kural değişirse tek yerde düzeltilir.

Object Mother ve Büyüme Sorunu

Object Mother daha eski bir fikir: hazır, isimli örnekler üreten bir factory class'ı. Fowler'ın bliki'sinde ThoughtWorks'teki kullanımından söz edilir:

// Object Mother: hazır, isimli örnekler
public static class TestOrders
{
    public static Order SmallDomesticOrder() =>
        OrderBuilder.AnOrder().WithBook(20m).Build();

    public static Order LargeDomesticOrderFromLoyalCustomer() =>
        OrderBuilder.AnOrder().ForLoyalCustomer().WithBook(60m).Build();

    public static Order SmallInternationalOrderFromLoyalCustomer() =>
        OrderBuilder.AnOrder().ShippedTo("DE").ForLoyalCustomer().WithBook(20m).Build();
}

Üç method okunur ve testte TestOrders.SmallDomesticOrder() niyeti iyi anlatıyor. Sorun büyüdüğünde başlıyor: ülke (2), sadakat (2), tutar (2) gibi üç boyut sekiz kombinasyon ve sekiz method demek. Bir test "küçük, yurt içi, sadık müşteri, iki satırlı" ister, yeni bir method eklenir; bir başka test mevcut bir method'un verisini biraz değiştirmek ister ve o method'u kullanan diğer testler sessizce etkilenir. Pryce'ın Test Data Builder'ı tam bu sorun için önerdi. Pratik denge: Object Mother'ı ekip içinde çok konuşulan birkaç kanonik örnek için kullan ve onu builder üzerine kur (yukarıdaki gibi); varyasyonları testler builder ile kendileri yapsın.

AutoFixture: Anonim Veri

Mark Seemann'ın AutoFixture'ı farklı bir soruya cevap veriyor: test bir değeri hiç önemsemiyorsa, onu neden yazayım? Constructor'ları reflection ile çağırıp bütün argümanları doldurur:

[Fact]
public void AutoFixture_fills_every_constructor_argument()
{
    Fixture fixture = new();

    Customer customer = fixture.Create<Customer>();

    Assert.StartsWith("Email", customer.Email);
    Assert.StartsWith("Country", customer.ShippingAddress.Country);
}

Ürettiği müşteri şuna benziyor (iki koşuda iki farklı değer):

AF: Customer { Id = Iddeb9b579-b338-4aa7-86f0-89c67948ba68, Name = Name01f4ab89-c81a-44db-9f7b-faeac6e9d12a, Email = Emailba9a5c7f-1d16-4905-b315-13e7dd2610af, ShippingAddress = Address { Street = Street07d69422-5754-4555-af5c-058feeb7b9d2, City = City15523257-7b57-4cf5-9568-aa98d015d6f6, PostalCode = PostalCode6c7d0df8-5250-495f-932d-51ebad661923, Country = Countryd9590a1b-55dd-4f0c-852a-e758735f0989 }, CompletedOrders = 43 }

String'ler parametre adı ve bir Guid, sayılar her koşuda farklı rastgele değerler (bu koşuda 43, bir sonrakinde 247). Bu "anonim" veri bilerek gerçekçi değil: Country "TR" olmadığı için bu müşteriyle yazılmış bir kargo testi her zaman yurt dışı kuralına düşer. AutoFixture, değerin gerçekten önemsiz olduğu testlerde parlıyor. xUnit entegrasyonuyla parametreler doğrudan test method'una gelir:

[Theory, AutoData]
public void Order_subtotal_sums_line_totals(Customer customer, Book book, Guid id)
{
    Order order = new(id, customer, [new OrderLine(book, 3)]);

    Assert.Equal(book.Price * 3, order.Subtotal);
}

AutoFixture.AutoNSubstitute ise interface'leri otomatik olarak NSubstitute sahtesiyle dolduruyor; Freeze ile aynı instance'ı hem teste hem test edilen koda verebiliyorsun. Paket sürümlerine dikkat: AutoFixture'ın son kararlı sürümü 4.18.1 (5.0 ise RC (Release Candidate) aşamasında) ve AutoFixture.AutoNSubstitute 4.18.1, NSubstitute'u < 6.0.0 ile sınırlıyor. NSubstitute 6.2.0 ile restore şu uyarıyı veriyor:

warning NU1608: Detected package version outside of dependency constraint: AutoFixture.AutoNSubstitute 4.18.1 requires NSubstitute (>= 2.0.3 && < 6.0.0) but version NSubstitute 6.2.0 was resolved.

Benim denediğim senaryolarda (Freeze ve Received) çalıştı, ama bu desteklenen bir kombinasyon değil. AutoFixture.Xunit3 4.19.0 ise xunit.v3.extensibility.core 1.0.0'a bağımlı ve xUnit 4.0.2 ile [AutoData] sorunsuz çalıştı. Böyle sürüm sınırları, 07. yazıdaki bağımlılık dersinin bir başka yüzü: test araçlarının da bakım takvimi var.

Bogus: Gerçekçi Veri ve Sabit Seed

Bogus, gerçekçi görünen veri üretir: isim, e-posta, adres, ürün adı, EAN (European Article Number) barkodu. UI (User Interface) ekran görüntüleri, demo veritabanları, büyük veriyle performans denemeleri ve sıralama, arama gibi gerçek verinin şeklinin önemli olduğu testler için değerli. Önce Tahmin Et'teki hatayı tekrar etmemek için iki kural var: testin dayandığı değeri rastgele bırakma, ve rastgeleliği seed ile sabitle:

[Fact]
public void Bogus_with_a_fixed_seed_is_deterministic()
{
    Faker<Customer> customers = new Faker<Customer>()
        .UseSeed(2026)
        .CustomInstantiator(f => new Customer(
            f.Random.Guid().ToString("N")[..8],
            f.Name.FullName(),
            f.Internet.Email(),
            new Address(f.Address.StreetAddress(), f.Address.City(), f.Address.ZipCode(), "TR"),
            f.Random.Int(0, 20)));

    Customer first = customers.Generate();
    TestContext.Current.TestOutputHelper?.WriteLine($"{first.Name} <{first.Email}> {first.CompletedOrders}");

    Assert.Equal("TR", first.ShippingAddress.Country);
}

Aynı seed ile iki ayrı koşuda ilk müşteri aynıydı: "John Bruen", "Pauline.Anderson80@yahoo.com", şehir "Blockmouth", 6 sipariş. Dikkat edersen isim ve e-posta birbirinden bağımsız üretilmiş; ikisinin tutarlı olması gerekiyorsa f.Internet.Email(firstName, lastName) gibi overload'larla bağlamak gerekir. Seed, kırmızı bir testi yeniden üretilebilir yapar; değeri hata mesajına ya da test çıktısına yazmak da kırmızının nedenini okunur yapar. Kural yine aynı: ülke "TR" sabit, çünkü test ona güveniyor.

DRY mı, DAMP mı?

Production kodunda tekrar bir koku; DRY (Don't Repeat Yourself) prensibi tekrar eden bilgiyi tek yere toplamayı söyler. Test kodunda denge farklı. Software Engineering at Google (2020) kitabının unit testing bölümü testler için DAMP'i (Descriptive And Meaningful Phrases) önerir: biraz tekrar, testin tek başına okunabilmesi için kabul edilebilir bir bedel. Bir testi anlamak için üç helper method'a, bir base class'a ve bir fixture'a atlamak gerekiyorsa, tekrar azaltılmış ama okunabilirlik kaybedilmiştir.

Pratik bir çizgi: object kurmanın mekaniği (constructor argümanları, zorunlu alanlar) builder'a gider, çünkü testin söylemek istediği şey değildir. Testin söylemek istediği değerler (49,99 EUR, "DE") testin içinde kalır, tekrar etse bile. Ortak assertion'lar için de aynı soru: AssertIsPaidOrder(order) gibi bir helper birkaç assert'i anlamlı bir isim altında topluyorsa iyi; yalnız satır kazanmak için yazılmışsa ve hata mesajını gizliyorsa kötü.

Üretimde Dikkat

Builder'ları test projesinin ortak bir klasöründe tut ve domain değiştiğinde onları da güncelle. Bir builder'ın varsayılanı geçersiz bir object üretmeye başlarsa (yeni bir doğrulama kuralı yüzünden), onu kullanan yüzlerce test aynı anda kırmızıya döner. Bu kötü bir şey değil: tek bir yeri düzeltirsin. Builder'sız bir suite'te aynı değişiklik yüzlerce testi tek tek düzeltmek demekti.

Rastgele veri kullanıyorsan seed'i görünür yap. Seed'i sabit bir değer olarak koymak en basit yol. Her koşuda farklı seed istiyorsan, seed'i test çıktısına yaz; kırmızı bir koşu, aynı seed'le yerel makinede yeniden üretilebilsin.

Bağımlılık sınırlarını izle. NU1608 uyarısını NoWarn ile susturmak yerine, ilgili paketin yeni sürümünü takip et ya da kombinasyonu bilinçli olarak kabul et ve nedenini proje dosyasında yorumla.

Sık Düşülen Tuzaklar

Rastgele veriyle ara sıra kırılan test. Önce Tahmin Et'in kendisi. Testin adındaki koşul (eşiğin altında) veriyle garanti edilmeli. Rastgelelik, testin önemsemediği alanlarda kalmalı.

Builder'ın içinde iş kuralı tekrar etmek. WithDiscountedTotal() gibi bir builder method'u indirimi kendi hesaplıyorsa, testler production'daki indirim kuralının bir kopyasına karşı çalışır; 03. yazıdaki "beklenen değeri production koduyla hesaplamak" hatasının builder'daki hâli. Builder girdiyi kurar, sonucu hesaplamaz.

Anonim veriye anlam yüklemek. AutoFixture'ın CompletedOrders = 43 değeri sadakat eşiğinin üstünde; bir test buna dayanırsa, AutoFixture'ın sayı üretme stratejisi değiştiğinde ya da başka bir testle sıra değiştiğinde test davranışı değişir. Önemsediğin değeri açıkça ver.

Object Mother'ı büyütmek. Kırkıncı TestOrders method'u eklendiğinde, hiçbir test diğerlerinin hangi veriye güvendiğini bilmiyordur. Varyasyonları builder'a bırak.

Küçük Bir Deneme

★☆☆ Önce Tahmin Et'teki testi düzelt: fiyat rastgele kalsın ama her zaman eşiğin altında olsun (faker.Random.Decimal(5m, 49.99m)). Testi 30 kez çalıştır ve fiyatı test çıktısına yaz.

★★☆ OrderBuilder'a WithCoupon(string code) ekle ve kupon kodu verilmediğinde varsayılanın ne olması gerektiğine karar ver. Mevcut testlerden hangileri değişmek zorunda kaldı?

★★★ Kendi projende en uzun Arrange bölümüne sahip on testi seç. Her biri için hangi değerlerin testin söylemek istediği şey olduğunu, hangilerinin yalnız constructor'ı memnun etmek için orada olduğunu işaretle. İkinci grubu bir builder'a taşıdığında Arrange kaç satıra indi?

Bir sonraki yazıda unit test'in en zor köşelerine giriyoruz: async kod, zaman, exception'lar, logging ve background service'ler; FakeTimeProvider.Advance ile timer'ları ilerletmek ve Channel<T> tüketicisini test etmek.