Mert Özen Her Satırda Daha İleri
Backend C# İleri Seviye — #14

C#'ta Unit Test: xUnit, Theory/InlineData ve Test Edilebilir Kod

Mert Özen 7 Kas 2026 8 dk 9 görüntülenme
C#'ta Unit Test: xUnit, Theory/InlineData ve Test Edilebilir Kod

Flaky bir testin sebebi olan gizli bağımlılıklar, xUnit test projesi, Fact ve Assert, Theory/InlineData/MemberData, zamanı IClock ile dışarıdan almak ve decimal InlineData, async void gibi tuzaklar.

Sürüm notu: Bu yazıdaki kodlar C# 10 ve .NET 6 API'leriyle yazılmıştır; .NET 10 SDK ile derlenip denenmiştir.

Önce Tahmin Et

Kitapçının eski fiyatlandırma kodunda hafta sonu indirimi var: cumartesi ve pazar fiyatlar yüzde 20 düşüyor. Birisi buna "indirimsiz fiyatı döndürür" diye bir test yazmış ve test aylardır yeşil. Bu yazıyı 7 Kasım 2026'da yazıyorum. Test bugün geçer mi?

namespace Bookstore;

public static class LegacyPricing
{
    public static decimal PriceFor(decimal listPrice) =>
        DateTime.Now.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday
            ? listPrice * 0.80m
            : listPrice;
}
using Bookstore;

public class LegacyPricingTests
{
    [Fact]
    public void PriceFor_returns_list_price()
    {
        decimal price = LegacyPricing.PriceFor(10m);

        Assert.Equal(10m, price);
    }
}

Test Neden Bugün Kırmızı

Geçmiyor:

[xUnit.net 00:00:00.05]     LegacyPricingTests.PriceFor_returns_list_price [FAIL]
  Failed LegacyPricingTests.PriceFor_returns_list_price [4 ms]
  Error Message:
   Assert.Equal() Failure: Values differ
Expected: 10
Actual:   8.00
Failed!  - Failed:     1, Passed:     0, Skipped:     0, Total:     1, Duration: 4 ms - Legacy.Tests.dll (net6.0)

7 Kasım 2026 bir cumartesi. Test pazartesiden cumaya geçiyor, hafta sonu kırılıyor; yani kodda bir değişiklik olmadan sonucu değişen bir test. Buna flaky test denir ve en tehlikeli test türüdür: ekip kırmızıyı görmezden gelmeyi öğrenir, sonra gerçek bir hata da görmezden gelinir. Sorun testte değil, kodda. PriceFor'un gizli bir girdisi var: DateTime.Now. Method signature'ında görünmeyen bu bağımlılık yüzünden test, hangi günü test ettiğini kontrol edemiyor. Üstüne DateTime.Now yerel saat; aynı an, farklı saat dilimindeki bir CI sunucusunda başka bir gün olabilir.

Bu yazıda xUnit ile test yazmayı göreceğiz, ama asıl ders bu: iyi test, test edilebilir koddan çıkar. Önce aracı kuralım, sonra bu kodu düzeltelim.

Test Projesi

Testler ayrı bir projede yaşar ve test edilen projeye referans verir. xUnit için üç paket gerekiyor; hepsi projenin kendi NuGet referansı, makineye global bir şey kurulmuyor:

<ItemGroup>
  <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
  <PackageReference Include="xunit" Version="2.9.3" />
  <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
  <Using Include="Xunit" />
  <ProjectReference Include="..\Bookstore.Core\Bookstore.Core.csproj" />
</ItemGroup>

xunit test yazmak için gereken attribute'ları ve Assert class'ını getirir. xunit.runner.visualstudio, dotnet test'in ve IDE'lerin xUnit testlerini bulup çalıştırmasını sağlayan adaptör. Microsoft.NET.Test.Sdk ise test altyapısının kendisi. <Using Include="Xunit" /> her dosyaya using Xunit; yazmamak için bir global using. Testleri çalıştırmak tek komut; bu makinede .NET 6 runtime olmadığı için roll-forward ile, ve çıktıdaki sayılar kültürden etkilenmesin diye en-US kültürüyle koştum:

LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 DOTNET_ROLL_FORWARD=Major dotnet test --logger "console;verbosity=normal"

Fact ve Assert

Test edeceğimiz ilk kod basit bir hesaplayıcı: birim fiyat çarpı adet, on ve üzeri adette yüzde 10 indirim, sıfır ya da negatif adette exception:

namespace Bookstore;

public sealed class PriceCalculator
{
    public decimal Total(decimal unitPrice, int quantity)
    {
        if (quantity <= 0)
            throw new ArgumentOutOfRangeException(nameof(quantity), quantity, "Quantity must be positive.");

        decimal total = unitPrice * quantity;
        if (quantity >= 10)
            total *= 0.90m;

        return Math.Round(total, 2);
    }
}

Testleri:

using Bookstore;

public class PriceCalculatorTests
{
    private readonly PriceCalculator _calculator = new();

    [Fact]
    public void Total_multiplies_unit_price_by_quantity()
    {
        // Arrange: hazırlık
        const decimal unitPrice = 9.99m;

        // Act: test edilen tek çağrı
        decimal total = _calculator.Total(unitPrice, 3);

        // Assert: beklenen sonuç
        Assert.Equal(29.97m, total);
    }

    [Theory]
    [InlineData(9, 90)]
    [InlineData(10, 90)]
    [InlineData(11, 99)]
    public void Total_applies_bulk_discount_from_ten_items(int quantity, int expected)
    {
        decimal total = _calculator.Total(10m, quantity);

        Assert.Equal(expected, total);
    }

    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    public void Total_rejects_non_positive_quantity(int quantity)
    {
        var ex = Assert.Throws<ArgumentOutOfRangeException>(() => _calculator.Total(10m, quantity));

        Assert.Equal("quantity", ex.ParamName);
    }
}

Test class'ı sıradan bir public class; test method'ları da sıradan public method'lar. [Fact] "bu method parametresiz bir test" der. İlk test, yorumlarda işaretlediğim Arrange-Act-Assert düzenini izliyor: girdileri hazırla, test edilen tek çağrıyı yap, sonucu doğrula. Her test tek bir davranışı doğrular ve adı o davranışı söyler; test kırıldığında adı okumak neyin bozulduğunu anlatmalı.

Assert.Equal'da sıra önemli: önce beklenen, sonra gerçek değer. Ters yazarsan test yine çalışır, ama kırıldığında hata mesajındaki Expected ve Actual yer değiştirir ve hata ayıklayan kişiyi yanıltır. xUnit her test için class'ın yeni bir instance'ını oluşturur; _calculator field'ı bu yüzden her testte tazedir ve testler birbirinin state'ini göremez.

Theory ve InlineData

İndirim eşiğini test ederken aynı testi farklı verilerle tekrar tekrar yazmak istemezsin. [Theory] parametreli bir test tanımlar, her [InlineData] satırı da ayrı bir test vakası olur. İndirim kurallarında en çok hata sınırda çıkar; bu yüzden 9, 10 ve 11'i ayrı ayrı deniyoruz. 9 adet indirimsiz 90, 10 adet indirimli yine 90, 11 adet indirimli 99.

Exception testi için Assert.Throws<T> var: verdiğin lambda'yı çalıştırır, tam olarak T tipinde bir exception bekler ve onu döndürür. Böylece exception'ın ayrıntılarını da doğrulayabilirsin; burada hangi parametrenin hatalı olduğunu (ParamName) kontrol ettik. Derived bir exception tipine de razıysan Assert.ThrowsAny<T> kullanırsın.

Ayrıntılı çıktıda her theory vakası, argümanlarıyla birlikte ayrı bir test olarak görünüyor:

  Passed PriceCalculatorTests.Total_applies_bulk_discount_from_ten_items(quantity: 10, expected: 90) [3 ms]
  Passed WeekendPricingTests.PriceFor_depends_only_on_the_given_day(now: 2026-11-07T00:00:00.0000000, expected: 8) [3 ms]
  Passed PriceCalculatorTests.Total_applies_bulk_discount_from_ten_items(quantity: 11, expected: 99) [< 1 ms]
  Passed WeekendPricingTests.PriceFor_depends_only_on_the_given_day(now: 2026-11-06T00:00:00.0000000, expected: 10) [< 1 ms]
  Passed PriceCalculatorTests.Total_applies_bulk_discount_from_ten_items(quantity: 9, expected: 90) [< 1 ms]
  Passed WeekendPricingTests.PriceFor_depends_only_on_the_given_day(now: 2026-11-08T00:00:00.0000000, expected: 8) [< 1 ms]
  Passed PriceCalculatorTests.Total_multiplies_unit_price_by_quantity [< 1 ms]
  Passed PriceCalculatorTests.Total_rejects_non_positive_quantity(quantity: -1) [< 1 ms]
  Passed PriceCalculatorTests.Total_rejects_non_positive_quantity(quantity: 0) [< 1 ms]
Test Run Successful.
Total tests: 9
     Passed: 9
 Total time: 0.2490 Seconds

Sıranın karışık olduğuna dikkat et. xUnit testlerin sırasını garanti etmez ve varsayılan olarak farklı test class'larını paralel çalıştırır. Bir testin başka bir testin bıraktığı state'e ihtiyaç duyması bu yüzden bir tasarım hatasıdır.

Test Edilebilir Kod: Zamanı Dışarıdan Almak

Şimdi açılıştaki koda dönelim. Çözüm, bir önceki yazıdaki dependency injection fikriyle aynı: gizli bağımlılığı görünür yap ve dışarıdan al. Zamanı bir interface'in arkasına koyuyoruz:

namespace Bookstore;

public interface IClock
{
    DateTime Now { get; }
}

public sealed class SystemClock : IClock
{
    public DateTime Now => DateTime.Now;
}

public sealed class WeekendPricing
{
    private readonly IClock _clock;

    public WeekendPricing(IClock clock) => _clock = clock;

    public decimal PriceFor(decimal listPrice) =>
        _clock.Now.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday
            ? listPrice * 0.80m
            : listPrice;
}

Uygulama SystemClock'u container'a kaydeder; test ise istediği günü döndüren küçük bir sahte saat (fake) verir:

using Bookstore;

public class WeekendPricingTests
{
    [Theory]
    [MemberData(nameof(Days))]
    public void PriceFor_depends_only_on_the_given_day(DateTime now, decimal expected)
    {
        var pricing = new WeekendPricing(new FixedClock(now));

        Assert.Equal(expected, pricing.PriceFor(10m));
    }

    public static TheoryData<DateTime, decimal> Days => new()
    {
        { new DateTime(2026, 11, 6), 10m },
        { new DateTime(2026, 11, 7), 8m },
        { new DateTime(2026, 11, 8), 8m },
    };

    private sealed class FixedClock : IClock
    {
        public FixedClock(DateTime now) => Now = now;

        public DateTime Now { get; }
    }
}

Test artık takvimden bağımsız: cuma, cumartesi ve pazarı her gün, her saat diliminde aynı sonuçla test ediyor. Burada InlineData yerine MemberData kullandık, çünkü DateTime ve decimal attribute argümanı olamaz (birazdan tuzaklarda göreceğiz). TheoryData<DateTime, decimal>, vakaları tip güvenli bir şekilde tanımlamanın yolu.

FixedClock için bir mocking kütüphanesine ihtiyaç duymadık; beş satırlık bir class yetti. Bağımlılık sayısı arttıkça Moq ya da NSubstitute gibi kütüphaneler işini kolaylaştırır; ASP.NET Core Web API serisindeki unit test yazısında service ve controller'ları Moq ile test ediyoruz. Ama ilk soru hep aynı kalır: bu kodun hangi girdileri gizli? DateTime.Now, Guid.NewGuid(), Random, dosya sistemi, ortam değişkenleri, network. Bunları dışarıdan aldığın an kod test edilebilir olur.

Sık Düşülen Tuzaklar

InlineData'ya decimal vermek. Para hesabı yapıyorsan ilk denediğin şey bu olur ve derlenmez:

using Bookstore;

public class PriceTests
{
    [Theory]
    [InlineData(9.99m, 3, 29.97m)]
    public void Total_with_decimal_data(decimal unitPrice, int quantity, decimal expected)
    {
        Assert.Equal(expected, new PriceCalculator().Total(unitPrice, quantity));
    }
}
PriceTests.cs(6,17): error CS0182: An attribute argument must be a constant expression, typeof expression or array creation expression of an attribute parameter type
PriceTests.cs(6,27): error CS0182: An attribute argument must be a constant expression, typeof expression or array creation expression of an attribute parameter type

Attribute argümanları compile-time sabiti olmalı ve decimal CLR açısından attribute'larda kullanılabilen bir sabit tipi değil (DateTime da öyle). Çözümler: değeri int ya da string olarak verip test içinde çevirmek (yukarıdaki indirim testinde beklenen değerleri int yazdık) ya da MemberData ile TheoryData<decimal, int, decimal> kullanmak.

async void test. Async kodu test ederken method'u async void yazmak kolaydır; xUnit 2 bunu hâlâ çalıştırır ama analyzer uyarır:

public class StockTests
{
    [Fact]
    public async void Reserve_reduces_stock()
    {
        await Task.Delay(10);
        Assert.Equal(4, 5 - 1);
    }
}
StockTests.cs(4,23): warning xUnit1048: Support for 'async void' unit tests is being removed from xUnit.net v3. To simplify upgrading, convert the test to 'async Task' instead. (https://xunit.net/xunit.analyzers/rules/xUnit1048)

Async testler her zaman async Task olmalı; böylece test framework'ü task'i bekleyip içindeki exception'ı doğru teste bağlayabilir. Async Programlama serisinde async void'in neden yalnızca event handler'lara ait olduğunu ayrıntılı konuştuk.

Paylaşılan static state. Her test yeni bir instance alır, ama static field'lar paylaşılır. Bir test bir static cache'e yazıp diğeri oradan okuyorsa, testler tek başına geçer, birlikte çalıştırıldığında rastgele kırılır. Static state'ten kaçamıyorsan o testleri aynı collection'a koyup paralel çalışmalarını engellersin, ama asıl çözüm genelde o state'i bir bağımlılık olarak dışarı almaktır.

Private method'u test etmeye çalışmak. Reflection ile private bir method'u çağırıp test etmek, testi implementasyon detayına bağlar; method'u yeniden adlandırdığında davranış değişmediği hâlde test kırılır. Public davranışı test et. Private bir parça gerçekten ayrı test edilmeyi hak edecek kadar karmaşıksa, kendi class'ına taşınmak istiyordur.

Küçük Bir Deneme

★☆☆ PriceCalculator'a, birim fiyat negatifse ArgumentOutOfRangeException fırlatan bir kontrol ekle ve önce testini yaz. Test kırmızıyken kodu yaz, sonra yeşile döndüğünü gör.

★★☆ Bulk indirim testini TheoryData<decimal, int, decimal> kullanan bir MemberData ile, 9.99m gibi gerçek fiyatlarla yeniden yaz. 10 adet × 9.99'un indirimli sonucu kaç ve Math.Round burada ne yapıyor?

★★★ Source generator yazısındaki DescribeGenerator için bir test projesi kur: CSharpCompilation.Create ile içinde [GenerateDescribe] olan bir class barındıran bir derleme oluştur, CSharpGeneratorDriver ile generator'ı çalıştır ve üretilen kaynağın Describe() içerdiğini doğrula.

Bu yazıyla C# İleri Seviye serisinin kemik yazıları bitiyor: delegate'lerden expression tree'lere, span'lerden source generator'lara, DI'dan unit test'e kadar hepsini C# 10 ve .NET 6 ile yazdık. Bundan sonra C# Sürümleri serisine geçiyoruz: önce C# 1.0'dan 10'a kısa bir tarih, ardından C# 11, 12, 13 ve 14'ün getirdikleri ve burada yazdığımız kodun yeni sürümlerde nasıl kısaldığı.