Mert Özen Her Satırda Daha İleri
Test ve Kalite Test Tasarım Teknikleri — #02

Equivalence Partitioning: Girdiyi Sınıflara Bölmek, Geçerli ve Geçersiz Partition'lar, Coverage ve xUnit Theory'ye Dökmek

Mert Özen 15 Kas 2026 12 dk 7 görüntülenme
Equivalence Partitioning: Girdiyi Sınıflara Bölmek, Geçerli ve Geçersiz Partition'lar, Coverage ve xUnit Theory'ye Dökmek

Equivalence partitioning, girdiyi kodun aynı davranması beklenen partition'lara böler ve her partition'ı bir testle kapsar; geçersiz partition'lar ise her biri ayrı testte denenir. Bookstore'un sipariş validator'ında iki geçersiz değeri birleştiren testler eksik misafir ödeme kuralını sakladı; partition tablosundan türetilen dokuz test hem onu hem tanımsız enum değeri hatasını yakaladı. Each Choice coverage, çıktı partition'ları ve TheoryData.

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

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) checkout'a giden her sipariş önce bir validator'dan geçiyor. Kurallar Selin'in story'sinde dört madde: sipariş adedi pozitif olmalı; kurumsal siparişte toplam en az 20 adet olmalı; açık hesap (kurumsal müşterinin faturayla, vadeli ödemesi) yalnızca kurumsal müşterilere açık; misafir müşteri yalnızca kredi kartıyla öder. Validator bulduğu bütün hataları birlikte döndürüyor, çünkü form hepsini aynı anda gösteriyor.

namespace Bookstore.Ordering;

public enum CustomerType
{
    Individual,
    Institutional,
    Guest,
}

public enum PaymentMethod
{
    CreditCard,
    BankTransfer,
    OnAccount,
}

public enum OrderError
{
    UnknownCustomerType,
    QuantityMustBePositive,
    BelowInstitutionalMinimum,
    PaymentMethodNotAllowed,
}

public sealed record OrderRequest(CustomerType Customer, int TotalQuantity, PaymentMethod Payment);

public static class OrderRequestValidator
{
    public static IReadOnlyList<OrderError> Validate(OrderRequest request)
    {
        if (!Enum.IsDefined(request.Customer))
        {
            return [OrderError.UnknownCustomerType];
        }

        List<OrderError> errors = [];
        if (request.TotalQuantity <= 0)
        {
            errors.Add(OrderError.QuantityMustBePositive);
        }
        else if (request.Customer == CustomerType.Institutional
                 && request.TotalQuantity < InstitutionalPricing.MinimumQuantity)
        {
            errors.Add(OrderError.BelowInstitutionalMinimum);
        }

        if (!IsAllowed(request.Customer, request.Payment))
        {
            errors.Add(OrderError.PaymentMethodNotAllowed);
        }

        return errors;
    }

    private static bool IsAllowed(CustomerType customer, PaymentMethod payment) => payment switch
    {
        PaymentMethod.OnAccount => customer == CustomerType.Institutional,
        _ => true,
    };
}

Emre geçersiz siparişler için üç test yazmış ve özellikle "zor" durumları seçtiğini söylüyor: her satırda birden fazla şey yanlış.

[Theory]
[InlineData(CustomerType.Guest, 0, PaymentMethod.BankTransfer)]
[InlineData(CustomerType.Guest, 5, PaymentMethod.OnAccount)]
[InlineData(CustomerType.Institutional, -1, PaymentMethod.CreditCard)]
public void Invalid_requests_are_rejected(CustomerType customer, int quantity, PaymentMethod payment)
{
    IReadOnlyList<OrderError> errors = OrderRequestValidator.Validate(new OrderRequest(customer, quantity, payment));

    Assert.NotEmpty(errors);
}

Üç test de geçiyor. Dört kuraldan biri kodda hiç yok. Hangisi, ve neden bu üç test onu fark etmiyor?

Cevap: Bir Hata Diğerini Saklıyor

Kodda olmayan kural misafirin ödeme kısıtı: IsAllowed yalnızca açık hesaba bakıyor, misafirin banka havalesiyle ödemesine izin veriyor. Üç test de bu kuralı içeriyor gibi duruyor, ama hiçbiri onu gerçekten denemiyor.

İlk satırda misafir hem 0 adet sipariş veriyor hem banka havalesiyle ödüyor. Validator 0 adet için QuantityMustBePositive döndürüyor, liste boş değil, test geçiyor. Ödeme kuralının çalışıp çalışmadığını bu test asla söyleyemez; adet hatası onun önüne geçti. İkinci satırı fark etmek daha zor: misafir açık hesapla ödüyor ve bu tek değer iki kuralı birden ihlal ediyor. Açık hesap kuralı hatayı üretiyor, misafir kuralı yine görünmüyor. Üçüncü satır ödeme kuralına hiç dokunmuyor.

Bu etkinin adı defect masking (bir hatanın ya da bir kontrolün, başka bir hatanın etkisini gizlemesi). Önceki yazıda da görmüştük: yuvarlama testi, minimum hatası yüzünden yuvarlamayı hiç denemeden kalmıştı. Bir testte birden fazla geçersiz değer olduğunda, testin sonucu yalnızca ilk yakalanan değerle ilgili bir şey söyler. Assert.NotEmpty gibi zayıf bir assertion da tabloyu tamamlıyor: hangi hatanın döndüğüne bakmadığı için, yanlış sebeple geçen testi ayırt edemiyor.

Çare bir teknikte: equivalence partitioning ve onun en basit kuralı, her geçersiz partition'ın kendi testinde, tek başına denenmesi.

Partition Bulmak

Equivalence partitioning (denklik sınıflarına bölme) bir girdinin olası değerlerini, test edilen kodun aynı şekilde işlemesi beklenen gruplara ayırır. Bu gruplara partition denir. Arkasındaki varsayım basit: bir partition'dan seçilen bir değer bir hatayı ortaya çıkarıyorsa, aynı partition'daki başka bir değer de ortaya çıkarır. Bu yüzden her partition için bir test yeterli sayılır. ISTQB (International Software Testing Qualifications Board; yazılım test sertifikalarını tanımlayan uluslararası kurul) syllabus'ı iki şart koyar: partition'lar birbiriyle çakışmaz ve hiçbiri boş değildir.

Partition'lar kuraldan çıkar. Sipariş adedi için kurallar şunu söylüyor: sıfır ve altı her müşteri için geçersiz; 1 ile 19 arası bireysel ve misafir için geçerli, kurumsal için geçersiz; 20 ve üstü herkes için geçerli. Burada ilk ders var: bir partition'ın geçerli olup olmadığı başka bir girdiye bağlı olabilir. 12 adet tek başına ne geçerli ne geçersiz; kurumsal müşteriyle birlikte geçersiz bir partition'a düşer. Bu yüzden "kurumsal ve 1–19 adet" kendi başına bir partition.

Enum'lar ilk bakışta kolay: her değer bir partition. CustomerType için bireysel, kurumsal ve misafir. Ama C#'ta enum aslında bir sayı ve tanımsız bir değer taşıyabilir. API'ye gelen JSON'da "payment": 9 yazıyorsa System.Text.Json bunu hata vermeden (PaymentMethod)9'a çeviriyor; denedik, JsonStringEnumConverter eklenmiş olsa bile sayısal değerler varsayılan olarak kabul ediliyor. "Tanımsız enum değeri" bu yüzden gerçek bir geçersiz partition'dır ve en sık unutulanıdır.

Ödeme yöntemi için ikinci ders var: geçersiz partition'ları kurala göre ayır, hata koduna göre değil. Açık hesabın kurumsal olmayan müşteriye kapalı olması bir kural, misafirin yalnızca kartla ödemesi başka bir kural. İkisi de PaymentMethodNotAllowed üretiyor, ama ayrı kurallar ayrı kod satırlarında yaşar ve ayrı ayrı bozulabilir. Bunları tek bir "izin verilmeyen ödeme" partition'ında birleştirip temsilci olarak açık hesabı seçseydik, misafir kuralının eksikliğini yine kaçırırdık. Emre'nin ikinci testinin tam olarak yaptığı da buydu.

Valid ve invalid kelimelerinin anlamı ekipten ekibe değişir. Syllabus da bunu açıkça söyler: geçersiz değer kimi ekipte kodun reddetmesi gereken değerdir, kimi ekipte spec'in hiç tanımlamadığı değerdir. Bu yazıda geçersiz partition, validator'ın bir hata kodu döndürmesi gereken partition demek. Önemli olan ekibin aynı tanımı kullanması.

Partition Tablosu

Kurallardan çıkan partition'lar, her birine bir ID vererek:

IDGirdiPartitionTür
EP1Müşteri tipiIndividualGeçerli
EP2Müşteri tipiInstitutionalGeçerli
EP3Müşteri tipiGuestGeçerli
EP4Müşteri tipiTanımsız enum değeriGeçersiz: UnknownCustomerType
EP5Adet0 ve altıGeçersiz: QuantityMustBePositive
EP6Adet1–19 (kurumsal dışı)Geçerli
EP7Adet20 ve üstüGeçerli
EP8Adet ve müşteriKurumsal ve 1–19Geçersiz: BelowInstitutionalMinimum
EP9ÖdemeCreditCardGeçerli
EP10ÖdemeBankTransfer (misafir dışı)Geçerli
EP11ÖdemeOnAccount (kurumsal)Geçerli
EP12Ödeme ve müşteriAçık hesap, kurumsal olmayan müşteriGeçersiz: PaymentMethodNotAllowed
EP13Ödeme ve müşteriMisafir, kart dışı ödemeGeçersiz: PaymentMethodNotAllowed
EP14ÖdemeTanımsız enum değeriGeçersiz: PaymentMethodNotAllowed

On dört partition, sekizi geçerli, altısı geçersiz. Tablo bir şeyi daha gösteriyor: int tipi bizi "abc" gibi sayı olmayan girdilerden koruyor. Bu partition yok olmadı, yalnızca katman değiştirdi; artık API'nin model binding'inde, ASP.NET Core Web API serisinde anlatılan 400 yanıtında yaşıyor. Hangi katmanda hangi partition'ın test edileceği Test Stratejisi serisinin "Neyi Hangi Seviyede Test Etmeli?" yazısının konusu.

Each Choice Coverage

Equivalence partitioning'de coverage item'lar partition'ların kendisidir. Coverage, en az bir testin denediği partition sayısının toplam partition sayısına oranıdır. Birden fazla girdi olduğunda en basit kriter Each Choice coverage: her girdinin her partition'ı en az bir testte yer alır. Kombinasyonlarla ilgilenmez; "kurumsal müşteri ve banka havalesi" birlikte denendi mi sorusunu sormaz.

Geçerli partition'lar için bu bize büyük bir tasarruf sağlar, çünkü bir test aynı anda üç girdinin birer geçerli partition'ını kapsayabilir. Sekiz geçerli partition üç testle kapsanır: bireysel, 5 adet, banka havalesi (EP1, EP6, EP10); kurumsal, 25 adet, açık hesap (EP2, EP7, EP11); misafir, 3 adet, kredi kartı (EP3, EP6, EP9). Üç test, çünkü en çok partition'ı olan girdinin üç geçerli partition'ı var.

Geçersiz partition'larda bu tasarruf yapılmaz. Her geçersiz partition kendi testinde, diğer girdiler geçerli partition'lardan seçilerek denenir. Myers'ın The Art of Software Testing kitabı (3. baskı 2011) bu kuralı geçerli ve geçersiz sınıflar için ayrı ayrı koyar: geçerli sınıflar aynı testte birleştirilebilir, geçersiz sınıfların her biri ayrı test ister. Sebep yukarıda gördüğümüz maskeleme. Geçersiz bir değer validator'ı tetiklediğinde, aynı testteki ikinci geçersiz değerin kontrolü ya hiç çalışmaz ya da sonucu ilk hatanın arkasında kaybolur.

Toplam: üç geçerli, altı geçersiz, dokuz test ve on dört partition'ın tamamı. Emre'nin üç testi de geçersiz partition'lara dokunuyordu, ama her birinde birden fazla geçersiz değer vardı: EP13'ü içeren iki testin ikisinde de başka bir geçersiz partition hatayı ondan önce üretiyordu.

Tablodan TheoryData'ya

Tablo neredeyse olduğu gibi test koduna dönüşüyor. TheoryData, MemberData ve TheoryDataRow'un mekaniğini Unit Test serisinin "xUnit v3'te Veri Odaklı Testler" yazısında anlatmıştık; burada yalnızca tablonun nasıl taşındığına bakıyoruz:

namespace Bookstore.Ordering.Tests;

public sealed class OrderRequestValidatorTests
{
    // Each Choice: her geçerli partition en az bir kez (EP1-EP3, EP6, EP7, EP9-EP11)
    public static TheoryData<CustomerType, int, PaymentMethod> ValidPartitions => new()
    {
        { CustomerType.Individual, 5, PaymentMethod.BankTransfer },
        { CustomerType.Institutional, 25, PaymentMethod.OnAccount },
        { CustomerType.Guest, 3, PaymentMethod.CreditCard },
    };

    [Theory]
    [MemberData(nameof(ValidPartitions))]
    public void Valid_partitions_are_accepted(CustomerType customer, int quantity, PaymentMethod payment)
    {
        IReadOnlyList<OrderError> errors = OrderRequestValidator.Validate(new OrderRequest(customer, quantity, payment));

        Assert.Empty(errors);
    }

    // Her test tek bir geçersiz partition; diğer değerler geçerli partition'lardan
    public static IEnumerable<TheoryDataRow<CustomerType, int, PaymentMethod, OrderError>> InvalidPartitions =>
    [
        new(CustomerType.Individual, -2, PaymentMethod.CreditCard, OrderError.QuantityMustBePositive) { Label = "EP5" },
        new(CustomerType.Institutional, 12, PaymentMethod.OnAccount, OrderError.BelowInstitutionalMinimum) { Label = "EP8" },
        new(CustomerType.Individual, 5, PaymentMethod.OnAccount, OrderError.PaymentMethodNotAllowed) { Label = "EP12" },
        new(CustomerType.Guest, 3, PaymentMethod.BankTransfer, OrderError.PaymentMethodNotAllowed) { Label = "EP13" },
        new((CustomerType)7, 5, PaymentMethod.CreditCard, OrderError.UnknownCustomerType) { Label = "EP4" },
        new(CustomerType.Individual, 5, (PaymentMethod)9, OrderError.PaymentMethodNotAllowed) { Label = "EP14" },
    ];

    [Theory]
    [MemberData(nameof(InvalidPartitions))]
    public void Each_invalid_partition_is_rejected_with_its_own_error(
        CustomerType customer, int quantity, PaymentMethod payment, OrderError expected)
    {
        IReadOnlyList<OrderError> errors = OrderRequestValidator.Validate(new OrderRequest(customer, quantity, payment));

        Assert.Equal([expected], errors);
    }
}

İki ayrıntı tablodan geliyor. Geçersiz testler NotEmpty değil Assert.Equal([expected], errors) kullanıyor: yalnızca bir hata beklendiği için, testin doğru sebeple kaldığını ya da geçtiğini assertion kanıtlıyor. Her satırın Label'ı partition ID'si; test çıktısında argüman listesi yerine bu etiket görünüyor ve kırmızı satırdan tabloya tek adımda dönülüyor. Temsilci değerler de bilerek "ortadan" seçildi: -2, 12, 25. Sınırın kendisi, yani 0, 1, 19 ve 20, bir sonraki yazının işi.

Eski koda karşı:

failed Bookstore.Ordering.Tests.OrderRequestValidatorTests.Each_invalid_partition_is_rejected_with_its_own_error [EP13] (1ms)
  Assert.Equal() Failure: Collections differ
                              ↓ (pos 0)
  Expected: <generated>      [PaymentMethodNotAllowed]
  Actual:   List<OrderError> []
failed Bookstore.Ordering.Tests.OrderRequestValidatorTests.Each_invalid_partition_is_rejected_with_its_own_error [EP14] (0ms)
  Assert.Equal() Failure: Collections differ
                              ↓ (pos 0)
  Expected: <generated>      [PaymentMethodNotAllowed]
  Actual:   List<OrderError> []

Test run summary: Failed!
  total: 17
  failed: 2
  succeeded: 15
  skipped: 0

İki kırmızı, iki ayrı hata. EP13 eksik misafir kuralını gösteriyor. EP14 ise kimsenin aramadığı bir hatayı buldu: tanımsız bir ödeme yöntemi _ => true koluna düşüyor ve kabul ediliyor. Yarın JSON'da "payment": 9 ile gelen bir sipariş ödeme adımına kadar ilerlerdi. Düzeltme IsAllowed'ı tuple üzerinden yazmak:

private static bool IsAllowed(CustomerType customer, PaymentMethod payment) => (customer, payment) switch
{
    _ when !Enum.IsDefined(payment) => false,
    (_, PaymentMethod.OnAccount) => customer == CustomerType.Institutional,
    (CustomerType.Guest, _) => payment == PaymentMethod.CreditCard,
    _ => true,
};

Kolların sırası da bir kural: tanımsız değer önce, açık hesap misafir kuralından önce. Misafir açık hesapla öderse iki kural da aynı sonucu veriyor, o yüzden sıra burada sonucu değiştirmiyor; ama bunun tesadüf olmadığını bilmek için o kombinasyonun da bir testi olmalı. Bunu Küçük Bir Deneme'ye bırakıyorum.

Çıktı Tarafında ve String'lerde Partition

Partition'lar yalnızca girdide aranmaz. Validator'ın çıktısı da partition'lara bölünür: boş liste, her hata kodu ve birden fazla hata. Her hata kodu yukarıdaki testlerde en az bir kez beklenen değer olarak geçiyor. "Birden fazla hata" ise henüz hiçbir testte yok, oysa form tam olarak bunu gösteriyor. Bu yüzden ona kendi testini veriyoruz:

// Çıktı partition'ı: birden fazla hata birlikte raporlanır
[Fact]
public void All_errors_are_reported_together()
{
    IReadOnlyList<OrderError> errors = OrderRequestValidator.Validate(
        new OrderRequest(CustomerType.Guest, 0, PaymentMethod.BankTransfer));

    Assert.Equal([OrderError.QuantityMustBePositive, OrderError.PaymentMethodNotAllowed], errors);
}
Test run summary: Passed!
  total: 18
  failed: 0
  succeeded: 18
  skipped: 0
  duration: 284ms

Bu test, Emre'nin ilk testindeki girdinin aynısı. Fark girdide değil, testin neyi iddia ettiğinde: burada iki geçersiz değerin birlikte gelmesi test edilen kuralın kendisi ve assertion iki hatayı da adıyla bekliyor. Geçersiz değerleri birleştirmek yasak değil; birleştirmenin sonucunu kesin olarak iddia etmeden birleştirmek sorun.

String girdilerde partition'ları format kuralları belirler. ISBN (International Standard Book Number; kitapların uluslararası kimlik numarası) için tipik partition'lar şunlar: geçerli 13 haneli bir ISBN, tirelerle yazılmış geçerli bir ISBN, check digit'i (son hanedeki kontrol rakamı) yanlış olan 13 hane, rakam dışı karakter içeren değer, 13'ten farklı uzunluk ve boş ya da null değer. Her biri ayrı bir kod yolundan geçer ve ayrı bir hata mesajı üretmesi beklenir. "13'ten farklı uzunluk" partition'ının hangi değerlerle deneneceği, yani 12 mi 14 mü yoksa ikisi de mi, boundary value analysis'in cevaplayacağı soru.

Üretimde Dikkat

Partition tablosu kodla birlikte yaşasın. Tablo bir wiki sayfasında kalırsa ilk kural değişikliğinde eskir. ID'lerin test verisinde Label olarak durması, tabloyu test sınıfının kendisine taşır: hangi partition'ın testi olmadığını görmek için test dosyasına bakmak yeter.

Yeni bir enum değeri eklendiğinde partition tablosu da değişir. Yarın PaymentMethod.Wallet gelirse, IsAllowed'ın _ => true kolu onu sessizce bütün müşterilere açar. Switch expression'ın son kolunu "kalan her şey geçerli" diye yazmak, yeni değerleri varsayılan olarak geçerli partition'a koymak demektir. Para ve yetkiyle ilgili kodda tersini tercih et: izin verilenleri açıkça say, kalanı reddet.

API sınırında geçersiz partition'lar çoğalır. Model binding, JSON converter'lar ve FluentValidation gibi kütüphaneler her biri bazı partition'ları kendi üstüne alır. Hangi partition'ın hangi katmanda reddedildiğini bilmek, aynı geçersiz değeri üç katmanda üç kez test etmeni ya da hiçbirinde etmemeni önler.

Production loglarındaki reddedilen istekler partition tablosunun gerçek hayattaki karşılığıdır. Hata kodlarına göre gruplanmış bir haftalık log, tabloda olmayan bir partition'ı (örneğin negatif değil ama int.MaxValue adet) sık sık gösterir.

Sık Düşülen Tuzaklar

İki geçersiz değeri aynı testte birleştirmek. Bu yazının ana konusu: test kalıyorsa hangi değerin tetiklediğini, geçiyorsa hangi kontrolün çalıştığını kaybedersin. Her geçersiz partition kendi testinde, diğer girdiler geçerli.

Zayıf assertion ile geçersiz partition test etmek. Assert.NotEmpty(errors) ya da Assert.Throws<Exception> yanlış sebeple geçen testi yakalamaz. Beklenen hata kodunu ya da exception tipini ve mesajını kesin olarak iddia et.

Hata koduna göre partition birleştirmek. Aynı hata kodu farklı kurallardan gelebilir. Partition'ları kurala göre ayır; aynı kodu üreten iki kural iki partition'dır.

Geçerli partition'ları kombinasyon sanmak. Each Choice coverage yüzde 100 olduğunda bile "kurumsal müşteri banka havalesiyle 20 adet" hiç denenmemiş olabilir. Kombinasyonların ne zaman önemli olduğu ve pairwise ile nasıl seçileceği altıncı yazının konusu.

Enum'un tanımsız değerlerini unutmak. C# enum'u kapalı bir küme değil; dışarıdan gelen her enum değeri bir sayıdır. Enum.IsDefined kontrolü ve onun testi olmadan EP14 gibi bir partition sessizce geçerli sayılır.

Küçük Bir Deneme

★☆☆ Emre'nin üç testini düzeltilmiş koda karşı çalıştır; hepsi yine geçiyor. Her satırın hangi partition'ları kapsadığını tablodaki ID'lerle yaz ve maskelenen partition'ları işaretle.

★★☆ Misafir müşterinin açık hesapla ödemesi iki kuralı birden ihlal ediyor. Bu kombinasyon için ayrı bir test yaz ve IsAllowed'daki iki kolun yerini değiştir. Test bir şey söylüyor mu? Söylemiyorsa neden söylemediğini açıkla.

★★★ PaymentMethod.Wallet ekle ve kuralı Selin'le konuştuğunu varsay: dijital cüzdan bireysel ve misafir müşterilere açık, kurumsal müşterilere kapalı. Önce partition tablosunu güncelle, sonra testleri, en son kodu. _ => true kolu olan eski hâlde hangi test kırmızı olurdu?

Bu yazıda temsilci değerleri bilerek partition'ların ortasından seçtik. Bir sonraki yazıda tam tersini yapacağız: boundary value analysis ile sınırın kendisine ve iki yanına bakacağız. 19, 20 ve 21; kampanyanın son saniyesi ve İstanbul saati ile UTC (Coordinated Universal Time; dünya genelinde referans alınan saat) arasındaki üç saat; 499,99 ve 500,00 TL; 12, 13 ve 14 haneli ISBN.