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

Snapshot ve Property-based Testing: Verify ve FsCheck ile Unit Test'in Sınırlarını Genişletmek

Mert Özen 8 Kas 2026 10 dk 3 görüntülenme
Snapshot ve Property-based Testing: Verify ve FsCheck ile Unit Test'in Sınırlarını Genişletmek

Property-based testing her girdi için doğru olan bir kuralı rastgele girdilerle dener, snapshot testing büyük çıktıyı bir bütün olarak onaylar; ikisi de örnek testleri tamamlar. FsCheck, üç örnek testin kaçırdığı negatif toplamı ilk denemede bulup 0 EUR'luk sepet ve 1 EUR'luk kupona küçülttü; Verify bir alan adı değişikliğini sözleşme farkı olarak yakaladı. Shrinking, özel arbitrary, scrubber'lar ve Verify 33'ün bakım ücreti kontrolü.

Sürüm notu: Bu yazıdaki kodlar .NET 10, xUnit.net v3 4.0.2, FsCheck.Xunit.v3 3.6.0 ve Verify.XunitV3 32.0.0 ile derlenip denenmiştir (Verify'ın neden 33.x değil 32.0.0 olduğu yazının içinde).

Önce Tahmin Et

Bookstore iki tür kupon dağıtıyor: yüzdelik (READ15: %15) ve sabit tutarlı (WELCOME10: 10 EUR). Hesap ve testleri:

public enum CouponKind { Percent, FixedAmount }

public sealed record Coupon(string Code, CouponKind Kind, decimal Value);

public static class CouponCalculator
{
    public static decimal DiscountFor(decimal subtotal, Coupon coupon) => coupon.Kind switch
    {
        CouponKind.Percent => Math.Round(subtotal * coupon.Value / 100m, 2, MidpointRounding.AwayFromZero),
        CouponKind.FixedAmount => coupon.Value,
        _ => throw new ArgumentOutOfRangeException(nameof(coupon)),
    };
}
public sealed class CouponExampleTests
{
    private static readonly Coupon TenOff = new("WELCOME10", CouponKind.FixedAmount, 10m);
    private static readonly Coupon FifteenPercent = new("READ15", CouponKind.Percent, 15m);

    [Theory]
    [InlineData(50, 10)]
    [InlineData(120, 10)]
    public void Fixed_coupon_takes_ten_euros_off(decimal subtotal, decimal expected) =>
        Assert.Equal(expected, CouponCalculator.DiscountFor(subtotal, TenOff));

    [Fact]
    public void Percent_coupon_takes_fifteen_percent_off() =>
        Assert.Equal(15.00m, CouponCalculator.DiscountFor(100m, FifteenPercent));
}

Üç örnek test de yeşil. Şimdi tek bir kural yazıyoruz: "hangi kupon olursa olsun, indirim ara toplamı asla geçmez". Bu kuralı FsCheck rastgele girdilerle denediğinde ne bulur?

[Property(StartSize = 1000)]
public bool Fixed_coupon_never_exceeds_the_subtotal(NonNegativeInt subtotalCents, PositiveInt couponEuros)
{
    decimal subtotal = subtotalCents.Get / 100m;
    Coupon coupon = new("F", CouponKind.FixedAmount, couponEuros.Get);

    return CouponCalculator.DiscountFor(subtotal, coupon) <= subtotal;
}

Cevap: İlk Denemede Karşı Örnek

Falsifiable, after 1 test (10 shrinks) (13807917890731852276,6346147742430517169).
Last step was invoked with size of 991 and seed of (15223041263418627218,17832169558009990981):
Original:
(NonNegativeInt 264, PositiveInt 451)
Shrunk:
(NonNegativeInt 0, PositiveInt 1)

FsCheck ilk denemede bir karşı örnek buldu: 2,64 EUR'luk bir sepete 451 EUR'luk sabit kupon. Sonra on adımda onu küçülttü (shrinking) ve en basit hâline indirdi: 0 EUR'luk sepet, 1 EUR'luk kupon. Sabit kupon ara toplamdan büyük olduğunda indirim ara toplamı aşıyor ve toplam negatife düşüyor. Örnek testler bunu yakalamadı, çünkü yazan kişi aklına gelen "normal" sepetleri seçti: 50 ve 120 EUR. Düzeltme Math.Min(coupon.Value, subtotal); ardından bütün testler yeşil.

Bu yazı example-based testlerin iki tamamlayıcısını ele alıyor: girdi uzayını tarayan property-based testing ve büyük çıktıları bir bütün olarak onaylayan snapshot testing.

Property-Based Testing: Property Düşünmek

Example-based test "bu girdi için bu çıktı" der. Property-based test "her girdi için doğru olan şu" der ve girdileri bir üretici seçer. Fikir Haskell'deki QuickCheck kütüphanesinden (Koen Claessen ve John Hughes, 2000) geliyor; FsCheck onun .NET karşılığı ve F# ile yazılmış olsa da C#'tan rahatça kullanılıyor.

Zor kısım araç değil, property'yi bulmak. Bookstore'dan birkaç tipik property türü:

  • Değişmez (invariant): indirim asla ara toplamı geçmez; toplam asla negatif olmaz; stok rezervasyondan sonra asla eksiye düşmez.
  • Gidiş-dönüş (round-trip): serialize edip deserialize edilen fatura aynıdır; bir ISBN'i (International Standard Book Number) biçimlendirip yeniden parse etmek aynı değeri verir.
  • Kıyas (oracle): yeni, hızlı fiyat hesaplayıcısı eskisiyle her girdide aynı sonucu verir.
  • Metamorfik: sepete bir kitap eklemek ara toplamı asla azaltmaz; satırların sırası toplamı değiştirmez.

Bu property'lerin hiçbiri beklenen değeri hesaplamıyor; yalnız sonuç hakkında doğru olması gereken bir şeyi söylüyor. Bu ayrım önemli, aşağıdaki tuzaklardan biri tam bununla ilgili.

FsCheck: Üreticiler ve Shrinking

FsCheck.Xunit.v3 paketi, xUnit v3 için [Property] attribute'unu getiriyor; 3.6.0, xunit.v3.extensibility.core'a [4.0.0, 5.0.0) aralığıyla bağımlı, yani xUnit 4.0 için derlenmiş. Test method'unun parametreleri üretilir. NonNegativeInt ve PositiveInt gibi sarmalayıcı tipler hem üretimi kısıtlar hem de shrinking'i bilir: küçültme yaparken 264'ü 0'a, 451'i 1'e doğru indirir. Kendi Gen'ini yazdığında (ör. Gen.Choose(0, 50_000).Select(...)) üretim çalışır ama shrinker olmadığı için karşı örnek küçülmez; aynı kuralı ilk denememde öyle yazdım ve FsCheck "0 shrinks" ile 2,99 EUR ve 37 EUR'luk bir kupon gösterdi. Okunur bir karşı örnek istiyorsan yerleşik tipleri ya da shrinker'ı olan arbitrary'leri tercih et.

StartSize = 1000, üretilen değerlerin büyüklüğünü artırıyor; varsayılan küçük boyutlarla başlayan bir koşuda karşı örnek zaten "1 ve 1" gibi küçük çıkıyordu ve shrinking'i göstermek için bu ayarı açtım. Her koşu farklı bir seed kullanıyor; çıktıdaki seed değerleri [Property(Replay = "...")] ile aynı koşuyu yeniden üretmek için.

FsCheck her tipi otomatik üretemiyor. Fatura record'u için gidiş-dönüş özelliğini ilk yazdığımda şu hatayı aldım:

System.Exception : The type System.Collections.Generic.IReadOnlyList`1[[Bookstore.Pricing.InvoiceLine, Bookstore.Pricing, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]] is not handled automatically by FsCheck. Consider using another type or writing a generator for it.

Çözüm, üreticiyi parçalardan kurmak ve bir arbitrary class'ı ile kaydetmek:

// IReadOnlyList<T> için FsCheck'e üreticiyi biz veriyoruz
public static class InvoiceArbitraries
{
    public static Arbitrary<Invoice> Invoices() =>
        (from id in ArbMap.Default.GeneratorFor<Guid>()
         from number in ArbMap.Default.GeneratorFor<NonNull<string>>()
         from issuedAt in ArbMap.Default.GeneratorFor<DateTimeOffset>()
         from email in ArbMap.Default.GeneratorFor<NonNull<string>>()
         from lines in ArbMap.Default.GeneratorFor<InvoiceLine[]>()
         from subtotal in ArbMap.Default.GeneratorFor<decimal>()
         from discount in ArbMap.Default.GeneratorFor<decimal>()
         select new Invoice(id, number.Get, issuedAt, email.Get, lines, subtotal, discount, subtotal - discount, "EUR"))
        .ToArbitrary();
}

public sealed class InvoiceJsonPropertyTests
{
    [Property(MaxTest = 500, Arbitrary = [typeof(InvoiceArbitraries)])]
    public bool Invoice_json_round_trips(Invoice invoice)
    {
        string json = InvoiceJson.Serialize(invoice);

        return InvoiceJson.Serialize(InvoiceJson.Deserialize(json)!) == json;
    }
}

500 rastgele faturayla üç ayrı koşuda geçti. Karşılaştırmayı JSON (JavaScript Object Notation) metni üzerinden yaptık, çünkü Invoice record'unun Lines listesi record eşitliğinde referansla karşılaştırılıyor; aynı içerikli iki liste "farklı" sayılırdı. Gidiş-dönüş özelliği burada bir hata bulmadı, ama serileştirme ayarı değiştiğinde (ör. bir converter eklendiğinde) ilk kırılacak test bu.

Snapshot Testing: Verify ile Büyük Çıktıyı Onaylamak

Bir faturanın on iki alanını tek tek Assert.Equal ile doğrulamak hem uzun hem eksik kalmaya mahkûm: yeni eklenen bir alan hiçbir testte görünmez. Snapshot (approval) testing çıktının bütününü bir dosyaya yazar ve sonraki koşularda o dosyayla karşılaştırır. Simon Cropp'un Verify'ı .NET'te bunun standart aracı:

[Fact]
public Task Invoice_for_two_books_with_a_fixed_coupon()
{
    Invoice invoice = InvoiceFactory.Create(
        "INV-2026-0042",
        "ada@example.com",
        [("978-0321125217", "Domain-Driven Design", 1, 55m), ("978-0134757599", "Refactoring", 2, 48m)],
        new Coupon("WELCOME10", CouponKind.FixedAmount, 10m),
        TimeProvider.System);

    return Verify(invoice);
}

İlk koşu her zaman kalır, çünkü henüz onaylanmış bir snapshot yok. Verify alınan çıktıyı .received.txt dosyasına yazar ve onu gösterir:

VerifyException : Directory: …/p12/Bookstore.Pricing.Tests
New:
  - Received: InvoiceSnapshotTests.Invoice_for_two_books_with_a_fixed_coupon.received.txt
    Verified: InvoiceSnapshotTests.Invoice_for_two_books_with_a_fixed_coupon.verified.txt

Received: InvoiceSnapshotTests.Invoice_for_two_books_with_a_fixed_coupon.received.txt
{
  Id: Guid_1,
  Number: INV-2026-0042,
  IssuedAt: DateTimeOffset_1,
  CustomerEmail: ada@example.com,
  Lines: [
    {
      Isbn: 978-0321125217,
      Title: Domain-Driven Design,
      Quantity: 1,
      UnitPrice: 55.0,
      LineTotal: 55.0
    },
    {
      Isbn: 978-0134757599,
      Title: Refactoring,
      Quantity: 2,
      UnitPrice: 48.0,
      LineTotal: 96.0
    }
  ],
  Subtotal: 151.0,
  Discount: 10.0,
  Total: 141.0,
  Currency: EUR
}

İki ayrıntı dikkat çekiyor. Id ve IssuedAt her koşuda farklı değerler, ama snapshot'ta Guid_1 ve DateTimeOffset_1 olarak görünüyor: Verify bu tipleri varsayılan olarak scrub ediyor, yani deterministik yer tutucularla değiştiriyor. Aynı Guid iki yerde geçseydi ikisi de Guid_1 olurdu; böylece ilişki korunur ama değer sabitlenmez. Kendi alanların için (sipariş numarası üreten bir sayaç gibi) ScrubMember ya da ScrubLinesContaining gibi ayarlar var. İkincisi, snapshot dosyasını okudum ve doğru olduğuna karar verdim (151 − 10 = 141); dosyayı .verified.txt olarak kaydettim ve sonraki koşu geçti. Bu adım insan kararıdır ve snapshot testing'in bütün değeri bu adımdadır.

Snapshot Değiştiğinde: Review

Bir refactoring sırasında biri Invoice'taki CustomerEmail'i Email olarak yeniden adlandırdı. Derleme geçti, örnek testlerin hepsi geçti. Snapshot testi:

NotEqual:
  - Received: InvoiceSnapshotTests.Invoice_for_two_books_with_a_fixed_coupon.received.txt
    Verified: InvoiceSnapshotTests.Invoice_for_two_books_with_a_fixed_coupon.verified.txt

Received: InvoiceSnapshotTests.Invoice_for_two_books_with_a_fixed_coupon.received.txt
{
  Id: Guid_1,
  Number: INV-2026-0042,
  IssuedAt: DateTimeOffset_1,
  Email: ada@example.com,

Fatura bir API (Application Programming Interface) yanıtıysa bu, istemcileri kıran bir sözleşme değişikliği. Snapshot testi değişikliği bulmakla kalmıyor, onu code review'a taşıyor: .verified.txt dosyası commit'lendiği için, değişikliği kabul eden kişi pull request'te snapshot farkını da gösteriyor. Değişiklik bilinçliyse yeni snapshot onaylanır; değilse kod geri alınır. Geliştirme makinesinde Verify, farkı bir diff aracında açmaya çalışır; CI (Continuous Integration) ortamında ve bu yazıdaki denemelerde DiffEngine_Disabled=true ortam değişkeniyle bunu kapattım.

Verify 33 ve Bakım Ücreti

Bu yazıda Verify.XunitV3'ün en güncel sürümü 33.3.4 değil, 32.0.0 kullanıldı. Nedeni 33.x ile gelen bir değişiklik: Verify'ın maintenance fee dokümanına göre 1 Eylül 2026'dan sonra yayımlanan sürümler, gelir üreten ve yıllık brüt geliri 10.000 USD ve üzeri olan kuruluşlar için aylık bir bakım ücretine tabi; v33'ten itibaren SponsorCheck adlı bir build adımı bunu kontrol ediyor. 33.3.4'ü eklediğimde build, SC021 koduyla ve "No license specified. Package 'Verify' requires a SponsorCheck license property." mesajıyla kaldı. Hata metni seçenekleri listeliyor: GitHub Sponsors hesabı, süreli lisans ya da süreli muafiyet beyanı (gelirsiz açık kaynak projeler, küçük gelirli kuruluşlar, bireyler muaf). Dokümana göre kontrol çalışma anında bağımlılık eklemiyor ve ağ çağrısı yapmıyor; kaynak kodu lisansı değişmiyor.

Bu bir lisans ve beyan konusu; senin adına muafiyet beyan edilecek bir şey değil. Bu yüzden yazıdaki örnekler, bu kapsamın dışında kalan 32.0.0 (26 Ağustos 2026) ile çalıştırıldı; xUnit 4.0.2 ile sorunsuz çalıştı. Kendi projende 33.x'e geçmeden önce kuruluşunun durumunu belirle ve gerekiyorsa ilgili MSBuild property'sini Directory.Build.props'a ekle. 03. yazıdaki FluentAssertions lisansı ve 07. yazıdaki Moq SponsorLink olayıyla birlikte üçüncü örnek: test bağımlılıklarının sürüm notlarını okumak, production bağımlılıklarınınki kadar önemli.

Hangi Problemde Değerli, Hangisinde Gürültü?

Property-based testing, değişmezi net olan hesaplamalarda (fiyat, indirim, stok, para birimi dönüşümü), parser ve serializer'larda ve iki implementation'ın karşılaştırılmasında değerli. İş akışı ağırlıklı kodda (siparişi kaydet, e-posta gönder) property bulmak zor ve testler zorlama kalır. Snapshot testing, büyük ve yapılı çıktılarda değerli: API yanıtları, raporlar, e-posta template'leri, üretilen SQL (Structured Query Language). Küçük bir sayıyı snapshot'la doğrulamak ise gürültü; Assert.Equal(141m, invoice.Total) daha okunur ve niyetini söyler.

İkisi de example-based testlerin yerini almaz. Örnek testler okura kuralı anlatır (WELCOME10 10 EUR indirir), property testi kuralın kenarlarını tarar, snapshot da çıktının bütününü korur. Sağlıklı bir suite'te üçü yan yana durur; Önce Tahmin Et'teki hata için en iyi kalıcı sonuç, FsCheck'in bulduğu karşı örneği bir InlineData satırı olarak örnek testlere de eklemek.

Üretimde Dikkat

Snapshot dosyaları kod gibi review edilir. .verified.txt dosyalarını commit'le, .received.txt dosyalarını .gitignore'a ekle. Büyük snapshot farkları pull request'te okunmadan onaylanma riski taşır; snapshot'ları küçük ve odaklı tut.

Property testlerinin süresini ve seed'ini yönet. Varsayılan 100 deneme çoğu durumda yeterli; MaxTest'i artırmak suite'i yavaşlatır. CI'da kalan bir property testinin seed'ini log'a yaz ve aynı seed'le yerelde Replay ile yeniden üret. FsCheck'in bulduğu her karşı örnek bir regresyon testine dönüşmeli.

Kültürden bağımsız snapshot. Ondalık ayraç, tarih biçimi ve sıralama kültüre bağlı; snapshot'ların farklı makinelerde farklı çıkmaması için 04. yazıda değindiğimiz culture ayarını sabitle.

Sık Düşülen Tuzaklar

Snapshot'ı okumadan kabul etmek. "Test kaldı, received dosyasını verified yap" refleksi, snapshot testing'i hatayı onaylayan bir makineye çevirir. Her kabul bir review kararıdır; okumadan kabul edilen snapshot, test yokmuş gibi davranır.

Property'yi implementation'ın kopyası olarak yazmak. DiscountFor(s, c) == Math.Min(c.Value, s) şeklinde bir property, production kodunu tekrar eder ve onunla birlikte yanılır. Property sonucu hesaplamamalı, sonuç hakkında doğru olması gereken bir şeyi söylemeli: sınırlar, ilişkiler, değişmezler.

Scrub edilmemiş değişken veri. Zaman, Guid, sıra numarası, makine adı snapshot'a ham hâliyle girerse test her koşuda kalır ve ekip snapshot'ları körlemesine kabul etmeye başlar. Verify'ın varsayılan scrubber'larının yetmediği alanlar için kendi scrubber'ını ekle ya da 09. yazıdaki gibi zamanı FakeTimeProvider ile sabitle.

Her şeyi snapshot'lamak. Bütün domain object'lerini snapshot'la doğrulayan bir suite, her refactoring'de yüzlerce dosya değişikliği üretir; bu, 01. yazıdaki refactoring'e dayanıksızlığın bir başka biçimi.

Küçük Bir Deneme

★☆☆ FsCheck'in bulduğu karşı örneği bir örnek teste çevir: Fixed_coupon_takes_ten_euros_off theory'sine "5 EUR'luk sepette WELCOME10 en fazla 5 EUR indirir" satırını ([InlineData(5, 5)]) ekle ve düzeltmeden önceki kodla bu satırın kaldığını gör.

★★☆ Metamorfik bir property yaz: bir faturaya yeni bir satır eklemek ara toplamı asla azaltmaz. Bunun için hangi üreticiye ihtiyacın var? Negatif fiyat üretilirse ne olur ve bu bir hata mı, eksik bir doğrulama mı?

★★★ InvoiceFactory.Create'i TimeProvider.System yerine FakeTimeProvider ile çağır ve snapshot'ta IssuedAt'in scrub edilmeden gerçek değeriyle görünmesini sağla (DontScrubDateTimes). Hangisi daha okunur, hangisi daha dayanıklı?

Bir sonraki ve son yazıda serinin başladığı yere dönüyoruz: xUnit v2 ile yazılmış eski test projelerini okumak ve onları adım adım v3'e ve 4.0'a taşımak.