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

xUnit v3'te Veri Odaklı Testler: Theory, MemberData, TheoryData, TheoryDataRow ve MatrixTheoryData

Mert Özen 8 Kas 2026 9 dk 3 görüntülenme
xUnit v3'te Veri Odaklı Testler: Theory, MemberData, TheoryData, TheoryDataRow ve MatrixTheoryData

Veri odaklı testte satır sayısından çok satır seçimi önemlidir: sınır değerleri ve equivalence partitioning, Math.Round'un banker's rounding hatasını iki satırla yakaladı. xUnit v3'te InlineData, MemberData ve tip güvenli TheoryData, satır bazında display name, label, trait ve skip taşıyan TheoryDataRow, MatrixTheoryData, dinamik skip, Explicit testler ve serileştirilemeyen verinin Test Explorer'a etkisi.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ve xUnit.net v3 4.0.2 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'a hacim indirimi geliyor: 100 EUR ve üzeri siparişte %10, kuruşa yuvarlanmış. Kod ve yuvarlamayı kontrol eden test:

namespace Bookstore.Pricing;

public sealed class DiscountPolicy
{
    // Hacim indirimi: 100 EUR ve üzeri siparişte %10
    public decimal VolumeDiscount(decimal subtotal) =>
        subtotal >= 100m ? Math.Round(subtotal * 0.10m, 2) : 0m;
}
public sealed class VolumeDiscountTests
{
    private readonly DiscountPolicy policy = new();

    [Theory]
    [InlineData(0, 0)]
    [InlineData(99.99, 0)]
    [InlineData(100, 10)]
    [InlineData(100.01, 10)]
    [InlineData(250, 25)]
    public void Ten_percent_from_100_euros(decimal subtotal, decimal expected)
    {
        Assert.Equal(expected, policy.VolumeDiscount(subtotal));
    }

    public static TheoryData<decimal, decimal> Rounding => new()
    {
        { 123.45m, 12.35m },
        { 100.05m, 10.01m },
        { 199.99m, 20.00m },
    };

    [Theory]
    [MemberData(nameof(Rounding))]
    public void Discount_is_rounded_to_the_cent(decimal subtotal, decimal expected)
    {
        Assert.Equal(expected, policy.VolumeDiscount(subtotal));
    }
}

Toplam sekiz satır var. Kaçı geçer? Muhasebeden gelen beklenen değerler elle hesaplanmış: 123,45'in %10'u 12,345, kuruşa yuvarlayınca 12,35.

Cevap: Banker's Rounding

failed Bookstore.Pricing.Tests.VolumeDiscountTests.Discount_is_rounded_to_the_cent(subtotal: 123.45, expected: 12.35) (7ms)
  Assert.Equal() Failure: Values differ
  Expected: 12.35
  Actual:   12.34
failed Bookstore.Pricing.Tests.VolumeDiscountTests.Discount_is_rounded_to_the_cent(subtotal: 100.05, expected: 10.01) (0ms)
  Assert.Equal() Failure: Values differ
  Expected: 10.01
  Actual:   10.00

Test run summary: Failed!
  total: 8
  failed: 2
  succeeded: 6

Sekiz satırdan ikisi kalıyor ve ikisi de tam ortada duran değerler: 12,345 ve 10,005. Math.Round'un varsayılanı MidpointRounding.ToEven, yani "banker's rounding": ortadaki değer en yakın çift sayıya yuvarlanır. 12,345 → 12,34 (4 çift), 10,005 → 10,00 (0 çift). Muhasebe ise "yarım yukarı" bekliyor. Düzeltme tek satır:

subtotal >= 100m ? Math.Round(subtotal * 0.10m, 2, MidpointRounding.AwayFromZero) : 0m;

Bu hatayı bulan şey veri seçimiydi: 199,99 ya da 250 gibi "rastgele" tutarlar hatayı göstermezdi. Bu yazı veri odaklı testlerin mekaniğini xUnit v3'ün araçlarıyla anlatıyor, ama asıl ders bu: hangi satırları yazdığın, kaç satır yazdığından daha önemli.

InlineData ve Veri Seçimi

[Theory], aynı test method'unu farklı argümanlarla çalıştırır; her satır ayrı bir test olarak raporlanır ve biri kaldığında diğerleri çalışmaya devam eder. Önceki yazıdaki döngülü testin tam karşılığı bu. [InlineData] en basit kaynak: değerler attribute'un içinde.

Attribute argümanları C#'ta decimal olamaz, bu yüzden [InlineData(99.99, 0)] aslında bir double taşıyor ve xUnit onu parametre tipine çeviriyor. 99,99 gibi değerlerde sorun çıkmıyor, ama çok basamaklı kesirlerde çeviri sürprizine karşı decimal testleri TheoryData ile yazmak daha güvenli.

Satırları seçerken iki teknik iş görür. Equivalence partitioning (denklik sınıfları): girdi uzayını, kodun aynı davrandığı gruplara, yani equivalence class'lara böl ve her gruptan bir temsilci seç. Hacim indirimi için iki grup var: 100'ün altı (indirim yok) ve 100 ve üzeri (indirim var). Boundary value analysis (sınır değer analizi): hatalar grupların kenarında toplanır, bu yüzden sınırın kendisini ve iki yanını dene. Eşik 100 ise 99,99, 100 ve 100,01; ayrıca alt uç olarak 0. >= yerine > yazıldığında ya da eşik 100 yerine 1000 olduğunda kırmızıya dönecek satırlar tam bunlar.

Yuvarlama da bir sınır problemi: ".5" değerleri yuvarlamanın sınırıdır. Bu yüzden yuvarlama testine 12,345 ve 10,005 gibi tam ortada duran değerler konmalı. Bir kuralın sınırı yalnız eşik değil; yuvarlama, boş koleksiyon, en büyük değer ve null da sınırdır.

MemberData, ClassData ve Tip Güvenli TheoryData

Değerler attribute'a sığmadığında (decimal, object, uzun listeler) [MemberData] bir static property ya da method'u işaret eder. Yukarıdaki Rounding property'si TheoryData<decimal, decimal> döndürüyor. Eski kodlarda sık görülen IEnumerable<object[]>'e göre farkı tip güvenliği: { 123.45m, "12.35" } gibi yanlış tipli bir satır derlenmez. xUnit analyzer'ları da MemberData'nın tipleriyle test parametrelerini karşılaştırıyor.

[ClassData(typeof(...))] aynı veriyi ayrı bir class'tan alır; aynı veri birden fazla test class'ında kullanılacaksa işe yarar. Tek bir class içinde kullanılan veri için MemberData, veriyi testin yanında tuttuğu için daha okunur. v3, veri kaynağının Task<> ya da ValueTask<> döndürmesine de izin veriyor; veriyi bir dosyadan async okuman gerekiyorsa bu kapı açık.

TheoryDataRow: Satır Bazında Metadata

v3'ün getirdiği en kullanışlı yeniliklerden biri, her satırın kendi metadata'sını taşıyabilmesi. TheoryDataRow<...>; skip, display name, label, timeout, trait ve explicit bilgisini satır seviyesinde verir:

public sealed class TierRateTests
{
    private readonly DiscountPolicy policy = new();

    public static TheoryData<CustomerTier, decimal> TierRates =>
    [
        new TheoryDataRow<CustomerTier, decimal>(CustomerTier.Regular, 0m),
        new TheoryDataRow<CustomerTier, decimal>(CustomerTier.Silver, 0.02m)
        {
            TestDisplayName = "Silver tier gets 2 percent",
        },
        new TheoryDataRow<CustomerTier, decimal>(CustomerTier.Gold, 0.05m)
        {
            Label = "gold",
            Traits = new() { ["Category"] = ["Loyalty"] },
        },
    ];

    [Theory]
    [MemberData(nameof(TierRates))]
    public void Card_payment_uses_tier_rate(CustomerTier tier, decimal expected)
    {
        Assert.Equal(expected, policy.TierRate(tier, PaymentMethod.Card));
    }
}

dotnet test --list-tests çıktısında üç satır üç farklı biçimde görünüyor:

  Bookstore.Pricing.Tests.TierRateTests.Card_payment_uses_tier_rate(tier: Regular, expected: 0)
  Silver tier gets 2 percent(tier: Silver, expected: 0.02)
  Bookstore.Pricing.Tests.TierRateTests.Card_payment_uses_tier_rate [gold]

TestDisplayName method adının yerine geçiyor, argümanlar yine ekleniyor. 4.0 ile gelen Label ise argüman listesinin yerine kısa bir etiket koyuyor; argümanları uzun object'ler olan satırlarda okunurluğu çok artırıyor. Gold satırındaki trait sayesinde dotnet test --filter-trait "Category=Loyalty" yalnız o satırı çalıştırdı (total: 1). Satır başına Skip ve Timeout da aynı şekilde verilebiliyor: bilinen bir hatası olan tek bir satırı, bütün theory'yi kapatmadan atlayabilirsin.

MatrixTheoryData: Kombinasyonlar

Kademe indirimi iki boyutlu: müşteri kademesi (Regular, Silver, Gold) ve ödeme yöntemi (Card, BankTransfer, GiftCard). Hediye kartıyla ödemede kademe indirimi uygulanmıyor. "Hiçbir kombinasyonda oran %5'i geçmez" gibi bir kural için bütün kombinasyonları elle yazmak yerine MatrixTheoryData cross product üretir:

public static MatrixTheoryData<CustomerTier, PaymentMethod> AllTiersWithGiftCard =>
    new([CustomerTier.Regular, CustomerTier.Silver, CustomerTier.Gold], [PaymentMethod.GiftCard]);

public static MatrixTheoryData<CustomerTier, PaymentMethod> EveryCombination =>
    new(Enum.GetValues<CustomerTier>(), Enum.GetValues<PaymentMethod>());

[Theory]
[MemberData(nameof(EveryCombination))]
public void Tier_rate_is_never_above_five_percent(CustomerTier tier, PaymentMethod payment)
{
    Assert.InRange(policy.TierRate(tier, payment), 0m, 0.05m);
}

[Theory]
[MemberData(nameof(AllTiersWithGiftCard))]
public void Gift_card_payment_gets_no_tier_rate(CustomerTier tier, PaymentMethod payment)
{
    Assert.Equal(0m, policy.TierRate(tier, payment));
}

EveryCombination 3 × 3 = 9 satır, AllTiersWithGiftCard 3 × 1 = 3 satır üretti. MatrixTheoryData 2 ile 5 arası veri kümesini birleştirebiliyor. Dikkat edilecek nokta, kombinasyonların çarpımla büyümesi: 5 boyut, her biri 4 değer, 1024 test. Matrix, "her kombinasyonda doğru olan" değişmezler (invariant) için iyi; her kombinasyona ayrı beklenen değer gerekiyorsa açık satırlar daha okunur. Değişmezleri bütün girdi uzayında aramanın daha güçlü aracı property-based testing; 12. yazıda FsCheck ile göreceğiz.

Dinamik Skip, Explicit ve Serileştirme

v2'de bir testi atlamanın tek yolu [Fact(Skip = "...")]'tı ve bu derleme anında sabitti. v3 çalışma anında karar vermeyi sağlıyor:

public sealed class SkipAndExplicitTests
{
    public static bool IsCi => Environment.GetEnvironmentVariable("CI") == "true";

    [Fact(Skip = "Gift card rules are being rewritten (BS-412)")]
    public void Gift_card_balance_covers_shipping() { }

    [Fact(SkipUnless = nameof(IsCi), Skip = "Runs only on the CI server")]
    public void Price_list_export_matches_the_published_catalog() { }

    [Fact]
    public void Turkish_culture_formats_discount_with_comma()
    {
        Assert.SkipWhen(
            OperatingSystem.IsBrowser(),
            "ICU data is not available in the browser");

        string text = 12.5m.ToString("N2", new System.Globalization.CultureInfo("tr-TR"));

        Assert.Equal("12,50", text);
    }

    [Fact(Explicit = true)]
    public void Recalculate_every_historical_order() { }
}
skipped Bookstore.Pricing.Tests.SkipAndExplicitTests.Price_list_export_matches_the_published_catalog
  Runs only on the CI server
skipped Bookstore.Pricing.Tests.SkipAndExplicitTests.Gift_card_balance_covers_shipping
  Gift card rules are being rewritten (BS-412)
skipped Bookstore.Pricing.Tests.SkipAndExplicitTests.Recalculate_every_historical_order
  Not run (due to explicit test filtering)

SkipUnless ve SkipWhen, bir public static bool property'nin adını alır; atlama mesajı Skip'te durur. SkipUnless ile SkipWhen'i aynı testte birlikte vermek çalışma anında hataya yol açar. Assert.Skip, Assert.SkipUnless ve Assert.SkipWhen ise kararı test gövdesinin içinde verir. xUnit dokümanı önemli bir ayrıntıyı belirtiyor: dinamik olarak atlanan test yine de tüm pipeline'dan geçer, yani class oluşturulur ve dispose edilir. Pahalı bir constructor'ı varsa bunu bil.

Explicit = true, testi yalnız açıkça istendiğinde çalıştırır. Uzun süren bir yeniden hesaplama ya da yalnız elle tetiklenmesi gereken bir doğrulama için uygun. Komut satırında --explicit on hepsini, --explicit only yalnız explicit testleri çalıştırır. Ters yönde, CI'da (Continuous Integration) hiç atlanan test kalmasın istiyorsan --fail-skips on atlanan testleri kalmış sayar.

Son olarak serileştirme. Test Explorer'ın bir theory'nin tek satırını çalıştırabilmesi için xUnit'in o satırın verisini keşif sırasında serileştirebilmesi gerekir. Yerleşik tipler, enum'lar ve v3'te eklenen Guid, Index, Range, Uri serileştirilebilir; kendi record'ların varsayılan olarak değil:

public sealed class BookDiscountTests
{
    public static TheoryData<Book, decimal> Books =>
    [
        (new Book("978-0321125217", "Domain-Driven Design", 55m), 0m),
        (new Book("978-0134757599", "Refactoring", 120m), 12m),
    ];

    [Theory]
    [MemberData(nameof(Books))]
    public void Single_book_volume_discount(Book book, decimal expected)
    {
        Assert.Equal(expected, new DiscountPolicy().VolumeDiscount(book.Price));
    }
}

--list-tests bu theory'yi tek satır olarak gösterdi (Bookstore.Pricing.Tests.BookDiscountTests.Single_book_volume_discount), keşfedilen toplam 28 test oldu; çalıştırınca iki satır ayrı ayrı koştu ve toplam 29'a çıktı. Sonuç: bu theory'nin tek satırını Test Explorer'dan çalıştıramazsın. Çözüm ya veriyi serileştirilebilir tiplerle vermek (kitabın ISBN'ini (International Standard Book Number) ve fiyatını ayrı parametreler yapmak) ya da v3'ün IXunitSerializer extension point'iyle kendi tipin için bir serializer kaydetmek. Çoğu durumda birincisi daha basit ve testi daha okunur yapıyor.

Üretimde Dikkat

Theory satırları ayrı testlerdir ve süreleri toplanır. 3 boyutlu bir matrix'i 10'ar değerle kurmak 1000 test demek; her biri 2 ms sürse bile 2 saniye. Unit test suite'inin hızı ekip için bir özellik; matrix'i değişmez kontrolü için kullan, satır sayısını gerekçelendir.

Skip'ler borçtur. [Fact(Skip = ...)] metnine bir iş kaydı numarası yaz (BS-412 gibi) ve atlanan test sayısını CI'da izle. Aylarca atlanan bir test ya düzeltilmeli ya silinmeli; sonsuza kadar sarı duran test hiçbir şeyi korumaz.

Kültür bağımlı testler. tr-TR ile biçimlendirme testi gibi kültüre bağlı testlerde kültürü açıkça ver; v3 ayrıca xunit.runner.json'da culture ayarıyla bütün testlerin kültürünü sabitlemeye izin veriyor. Varsayılan, makinenin kültürü; geliştiricinin makinesinde geçen ve CI'da kalan test burada başlar.

Sık Düşülen Tuzaklar

Tek theory'ye ilgisiz senaryoları doldurmak. [InlineData(50, "Gold", "Card", true, 2.5)] gibi beş parametreli, her satırı başka bir kuralı deneyen bir theory, döngülü testin kılık değiştirmiş hâli. Parametre sayısı üçü geçiyor ve satırlar farklı kuralları deniyorsa, onları kurala göre ayrı theory'lere böl.

Beklenen değeri production koduyla hesaplamak. Assert.Equal(Math.Round(subtotal * 0.10m, 2), policy.VolumeDiscount(subtotal)) şeklinde yazılmış bir theory, bu yazının başındaki hatayı asla bulamazdı: test de aynı banker's rounding'i yapar ve 12,34'ü onaylar. Beklenen değer, kuralı bilen birinin elle hesapladığı sabittir.

Yalnız "mutlu" değerleri seçmek. 150, 200, 250 ile yazılmış bir indirim theory'si üç satır ama tek bir equivalence class. Sınırlar, yuvarlama noktaları ve uç değerler yoksa satır sayısı yanıltıcı bir güven verir.

Serileştirilemeyen veriyle Test Explorer'da tek satır çalıştırmayı beklemek. Theory'yi yazdığın gün değil, üç ay sonra tek bir satırı debug etmen gerektiğinde fark edersin. Karmaşık object yerine basit parametreler ve test içinde object kurmak bu sorunu ortadan kaldırır.

Küçük Bir Deneme

★☆☆ Ten_percent_from_100_euros theory'sine yuvarlama düzeltmesinden önce hatayı yakalayacak tek bir InlineData satırı ekle. Satırın neden sınır değeri olduğunu yaz.

★★☆ TierRates verisine bir satır ekle: Gold kademesi ama bilinen bir hata yüzünden atlanması gereken bir senaryo. Yalnız o satırı Skip ile atla, diğerlerinin çalıştığını dotnet test çıktısıyla doğrula.

★★★ BookDiscountTests'i Test Explorer'da satır satır çalıştırılabilir hâle getir: önce parametreleri basit tiplere çevirerek, sonra Book için bir IXunitSerializer yazarak. İki çözümün kod miktarını ve test adlarının okunurluğunu karşılaştır.

Bir sonraki yazıda testlerin nasıl oluşturulup yok edildiğine bakıyoruz: her test için yeni class instance'ı, IAsyncLifetime, class, collection ve assembly fixture'ları ve 4.0'ın getirdiği tam paralellik.