Kombinatoryal Test: Pairwise, Orthogonal Array ve PICT ile Konfigürasyon Patlamasını Yönetmek
Pairwise testing, her iki parametrenin her değer çiftini en az bir testte yan yana getirerek tam kombinasyonun maliyetini düşürür; hataların çoğunun bir ya da iki parametrenin etkileşiminden çıktığı varsayımına dayanır. Bookstore'un checkout'unda each choice'un üç testi eksik İngilizce mağazadan teslim template'ini kaçırdı; PICT'in constraint'li modelden ürettiği 11 satır yakaladı. t-wise, orthogonal array, seed, oracle ve Playwright project'leri.
Sürüm notu: Bu yazıdaki kodlar .NET 10, xUnit.net v3 4.0.3 ve PICT (Pairwise Independent Combinatorial Testing; Microsoft'un kombinasyon üreten komut satırı aracı) 3.7.4 ile derlenip denenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) checkout'a mağazadan teslim seçeneği eklendi. Sipariş onaylandığında sistem iki şeye karar veriyor: siparişin kaç iş günü içinde kargoya ya da mağazaya çıkacağı ve müşteriye hangi onay e-postası template'inin gideceği. Kararı dört parametre belirliyor: müşteri tipi (bireysel, kurumsal, misafir), ödeme yöntemi (kredi kartı, havale, açık hesap), teslimat tipi (standart, express, mağazadan teslim) ve dil (Türkçe, İngilizce).
namespace Bookstore.Ordering;
public enum DeliveryType
{
Standard,
Express,
StorePickup,
}
public sealed record CheckoutRequest(CustomerType Customer, PaymentMethod Payment, DeliveryType Delivery, string Language);
public sealed record CheckoutPlan(int DispatchInBusinessDays, string ConfirmationTemplate);
public static class ConfirmationTemplates
{
// Her teslimat tipi ve dil için bir onay e-postası template'i
public static readonly IReadOnlySet<string> Names = new HashSet<string>
{
"standard.tr",
"standard.en",
"express.tr",
"express.en",
"store-pickup.tr",
};
}
public static class CheckoutPlanner
{
public const int MaxDispatchInBusinessDays = 3;
public static CheckoutPlan Plan(CheckoutRequest request)
{
int days = request.Delivery == DeliveryType.Express ? 0 : 1;
if (request.Payment == PaymentMethod.BankTransfer)
{
days++; // havalenin hesaba geçmesi beklenir
}
if (request.Customer == CustomerType.Institutional)
{
days++; // kurumun satın alma onayı beklenir
}
string template = $"{TemplatePrefix(request.Delivery)}.{request.Language}";
if (!ConfirmationTemplates.Names.Contains(template))
{
throw new InvalidOperationException($"No confirmation template named '{template}'.");
}
return new CheckoutPlan(days, template);
}
private static string TemplatePrefix(DeliveryType delivery) => delivery switch
{
DeliveryType.Standard => "standard",
DeliveryType.Express => "express",
DeliveryType.StorePickup => "store-pickup",
_ => throw new ArgumentOutOfRangeException(nameof(delivery)),
};
}
Emre ikinci yazıdaki each choice coverage'ı uyguluyor: her parametrenin her değeri en az bir testte geçsin. En çok değeri olan parametrede üç değer var, o yüzden üç satır yetiyor:
Customer Payment Delivery Language
Individual BankTransfer StorePickup tr
Guest CreditCard Express en
Institutional OnAccount Standard tr
Test run summary: Passed!
total: 3
failed: 0
succeeded: 3
skipped: 0
duration: 309ms
On bir değerin hepsi denendi ve üç test de yeşil. İngilizce arayüzden mağazadan teslim seçen bir müşteri siparişini tamamlayabilir mi?
Cevap: Hata İki Değerin Kesişiminde
Tamamlayamaz. Mağazadan teslim seçeneği geçen ay eklenirken Türkçe onay e-postası yazıldı, İngilizcesi unutuldu. store-pickup.en adında bir template yok ve Plan exception fırlatıyor. Each choice testleri StorePickup'ı denedi, İngilizceyi de denedi, ama ikisini aynı satırda hiç denemedi.
Hatayı tetikleyen tek bir değer değil, iki değerin birlikte gelmesi. Equivalence partitioning her parametreyi ayrı ayrı ele alıyordu, decision table ise koşulların kombinasyonuna bakıyordu ama sonucun koşullara bağlı olduğunu bildiğimiz kurallar için. Burada parametrelerin birbirini etkilemesi beklenmiyor; dil ile teslimat tipi arasında yazılmış bir kural yok. Hata, kimsenin kural olarak düşünmediği bir kesişimde. Bu yazının tekniği, kombinatoryal test, tam bu tür kesişimleri sistematik olarak kapsamak için var.
Tam Kombinasyonun Maliyeti
İlk akla gelen cevap bütün kombinasyonları denemek. xUnit v3'teki MatrixTheoryData bunu tek satırda yapıyor; mekaniği Unit Test serisinin "xUnit v3'te Veri Odaklı Testler" yazısında:
// Tam kombinasyon: 3 x 3 x 3 x 2 = 54 satır, kısıtlar yok
public static MatrixTheoryData<CustomerType, PaymentMethod, DeliveryType, string> FullMatrix =>
new(Enum.GetValues<CustomerType>(), Enum.GetValues<PaymentMethod>(), Enum.GetValues<DeliveryType>(), ["tr", "en"]);
failed Bookstore.Ordering.Tests.CheckoutPlannerComparisonTests.Every_combination_gets_a_plan(customer: Guest, payment: BankTransfer, delivery: StorePickup, language: "en") (0ms)
failed Bookstore.Ordering.Tests.CheckoutPlannerComparisonTests.Every_combination_gets_a_plan(customer: Institutional, payment: BankTransfer, delivery: StorePickup, language: "en") (0ms)
failed Bookstore.Ordering.Tests.CheckoutPlannerComparisonTests.Every_combination_gets_a_plan(customer: Individual, payment: CreditCard, delivery: StorePickup, language: "en") (0ms)
Test run summary: Failed!
total: 54
failed: 9
succeeded: 45
skipped: 0
duration: 255ms
Elli dört kombinasyon, dokuz kırmızı. Hata yakalandı, ama iki sorun var. Birincisi, dokuz kırmızıdan altısı gerçekte oluşamayacak siparişler: misafir müşteri havale ya da açık hesapla ödeyemez, açık hesap yalnız kurumsal müşteriye açık, kurumsal siparişler de mağazadan teslim alınmaz. Elli dört satırın yirmi dördü böyle imkânsız kombinasyonlar. İkincisi, sayı hızla büyüyor. Unit seviyede 54 test bir saniyenin altında koşuyor ve burada tam matris gerçekten seçilebilir. Ama aynı checkout'u tarayıcıda üç farklı engine'le test ettiğinde geçerli kombinasyon sayısı 90 olur; her biri bir sipariş akışını baştan sona yürüten 90 E2E (end-to-end; uygulamayı kullanıcının gördüğü hâliyle baştan sona çalıştıran test) testi, dakikalar süren bir pipeline demek. Her yeni parametre bu sayıyı değer sayısıyla çarpıyor.
Pairwise'ın Varsayımı, t-wise ve Orthogonal Array
Pairwise testing bir varsayıma dayanır: hataların büyük kısmı tek bir parametrenin değerinden ya da iki parametrenin etkileşiminden çıkar. Bu varsayımın arkasında ölçüm var. NIST'te (National Institute of Standards and Technology; ABD'nin standartlar enstitüsü) Kuhn, Wallace ve Gallo'nun 2004'te IEEE (Institute of Electrical and Electronics Engineers; elektrik ve elektronik mühendisleri derneği) Transactions on Software Engineering'de yayımladığı çalışma ve sonraki araştırmalar, incelenen sistemlerde hataların çoğunun bir ya da iki parametreyle tetiklendiğini, üç ve daha fazla parametre gerektirenlerin giderek azaldığını ve gözlenen etkileşim derecesinin altıyı geçmediğini raporlar. NIST bu gözleme "interaction rule" adını veriyor. Aynı kaynaklar bir uyarı da yapıyor: bazı hatalar ancak üç ya da daha fazla koşul birlikte gelince ortaya çıktığı için tek başına pairwise her zaman yeterli değildir.
Pairwise'ın hedefi şu: herhangi iki parametrenin her değer çifti en az bir testte yan yana gelsin. Bir test satırı aynı anda birçok çifti kapsadığı için satır sayısı tam kombinasyondan çok daha az olur. Bu genellemenin adı t-wise: t = 1 each choice, t = 2 pairwise, t = 3 her üçlü, t = parametre sayısı ise tam kombinasyon. Bu yazının modelinde aynı aracın ürettiği sayılar şöyle: each choice 3 satır, pairwise 11, 3-wise 22, tam kombinasyon 30.
Orthogonal array bu fikrin daha eski ve daha katı hâli. Taguchi'nin deney tasarımında kullandığı L9 (dokuz satırlık orthogonal array), üç değerli dört parametreyi dokuz satırla dengeli biçimde kapsar: her iki sütunun her değer çifti tam olarak bir kez geçer. Pairwise ise "en az bir kez" ister, eşitlik aramaz. Bu fark pratikte önemli: orthogonal array bütün parametrelerin aynı sayıda değeri olmasını ister ve kısıtları sevmez. L9'u müşteri tipi, ödeme ve teslimat sütunlarına yerleştirince dokuz satırdan dördü imkânsız kombinasyon çıkıyor; onları silmek de dengeyi ve bazı çiftleri birlikte götürüyor. Kısıtları olan bir iş kuralında pairwise aracı daha kullanışlı.
PICT ile Model ve Constraint'ler
Microsoft'un PICT aracı düz metin bir model dosyasını okuyup test satırlarını üretir. Son release'i v3.7.4; GitHub'daki release'te Windows için pict.exe var, Linux ve macOS'ta kaynak koddan CMake ile derleniyor. Model dosyası önce parametreleri, sonra constraint'leri içerir:
# Bookstore checkout: müşteri tipi, ödeme, teslimat ve dil
Customer: Individual, Institutional, Guest
Payment: CreditCard, BankTransfer, OnAccount
Delivery: Standard, Express, StorePickup
Language: tr, en
# Misafir yalnız kredi kartıyla öder; açık hesap yalnız kurumsal müşteriye açık
IF [Customer] = "Guest" THEN [Payment] = "CreditCard";
IF [Payment] = "OnAccount" THEN [Customer] = "Institutional";
# Kurumsal siparişler mağazadan teslim alınmaz
IF [Customer] = "Institutional" THEN [Delivery] <> "StorePickup";
Constraint'ler ikinci yazıdaki OrderRequestValidator'ın kurallarını ve teslimat kuralını tekrar ediyor. Bunları üretimden sonra filtrelemek yerine modele yazmanın sebebini PICT dokümantasyonu iyi açıklıyor: imkânsız bir satırı sonradan silersen, o satırın kapsadığı geçerli çiftler de kaybolur. Constraint'i bilen araç geçerli çiftleri başka satırlara yerleştirir.
> pict checkout.pict
Customer Payment Delivery Language
Institutional BankTransfer Standard en
Institutional OnAccount Express tr
Individual BankTransfer StorePickup tr
Individual CreditCard Express en
Institutional BankTransfer Express en
Individual CreditCard Standard tr
Guest CreditCard StorePickup en
Guest CreditCard Express tr
Institutional CreditCard Standard en
Guest CreditCard Standard en
Institutional OnAccount Standard en
> pict checkout.pict /s
Combinations: 40
Generated tests:11
Generation time:0:00:00
/s seçeneği modelin istatistiğini veriyor: constraint'lerden sonra kapsanması gereken 40 değer çifti var ve PICT bunları 11 satıra sığdırmış. Yedinci satır, misafir müşterinin İngilizce arayüzden mağazadan teslim seçtiği sipariş. Each choice'un üç satırında bu çift yoktu; pairwise'da olmak zorunda.
Seed, Sürüm ve Tekrarlanabilirlik
Model ve seçenekler aynı kaldıkça PICT her çalıştırmada aynı çıktıyı verir. /r seçeneği üretimi rastgele hâle getirir ve kullandığı seed'i hata çıktısına yazar; /r:N aynı seed'le aynı çıktıyı tekrar üretir. Seed satır sayısını da değiştiriyor, çünkü çiftleri en az satıra sığdırmak zor bir problem ve araç sezgisel bir algoritma kullanıyor:
seed 1: 10
seed 2: 12
seed 3: 10
seed 4: 11
seed 5: 11
Hepsi aynı 40 çifti kapsıyor; fark yalnızca paketlemenin verimliliğinde. Sürüm de çıktıyı etkiliyor: PICT'in GitHub'daki main dalı henüz release edilmemiş performans iyileştirmeleri içeriyor ve aynı model için farklı satırlar üretiyor. Bu yüzden üretilen dosyayı model dosyasıyla birlikte repository'ye commit'liyoruz; testin girdisi PICT'in o günkü davranışına değil, review edilmiş bir dosyaya bağlı.
Bir satırı her durumda tutmak istersen, örneğin bir zamanlar hata bulmuş bir kombinasyonu, /e seçeneğiyle seed dosyası verirsin. Dosya PICT çıktısıyla aynı formatta; PICT önce bu satırları yerleştirir, sonra kalan çiftleri onların üzerine kurar. Hatayı tetikleyen kombinasyonlardan biri olan Individual, CreditCard, StorePickup, en satırıyla denediğimizde ilk satır o oluyor ve toplam yine 11 satır çıkıyor.
PICT Çıktısından TheoryData'ya
PICT'in çıktısı tab ile ayrılmış bir metin: ilk satır parametre adları, sonraki her satır bir test case. Dosyayı test projesine koyup derleme çıktısına kopyalıyoruz:
<ItemGroup>
<None Include="Pict\*.txt" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
MemberData dosyayı okuyup her satırı bir TheoryDataRow'a çeviriyor:
namespace Bookstore.Ordering.Tests;
public sealed class CheckoutPlannerPairwiseTests
{
// PICT çıktısı: ilk satır parametre adları, sonraki her satır bir test case (tab ile ayrılmış)
public static IEnumerable<TheoryDataRow<CustomerType, PaymentMethod, DeliveryType, string>> Pairs =>
File.ReadLines(Path.Combine(AppContext.BaseDirectory, "Pict", "checkout-pairs.txt"))
.Skip(1)
.Select(line => line.Split('\t'))
.Select(v => new TheoryDataRow<CustomerType, PaymentMethod, DeliveryType, string>(
Enum.Parse<CustomerType>(v[0]), Enum.Parse<PaymentMethod>(v[1]), Enum.Parse<DeliveryType>(v[2]), v[3]));
[Theory]
[MemberData(nameof(Pairs))]
public void Every_valid_pair_gets_a_plan(
CustomerType customer, PaymentMethod payment, DeliveryType delivery, string language)
{
CheckoutPlan plan = CheckoutPlanner.Plan(new CheckoutRequest(customer, payment, delivery, language));
// Oracle: her satır için ayrı beklenen değer yok; her kombinasyonda geçerli olması gerekenler var
Assert.EndsWith($".{language}", plan.ConfirmationTemplate);
Assert.InRange(plan.DispatchInBusinessDays, 0, CheckoutPlanner.MaxDispatchInBusinessDays);
}
}
Enum.Parse model dosyasındaki değerlerle C# tiplerini birbirine bağlıyor: modelde bir değerin adı yanlış yazılırsa test, veri yüklenirken açık bir hatayla durur. Assert'lere de dikkat: her satır için ayrı bir beklenen değer yazmıyoruz. Kombinatoryal testin zor tarafı oracle, yani bir test'in sonucunun doğru olup olmadığına neyin karar verdiği. Satır başına beklenen değer yazmak, decision table'ı yeniden yazmak demek. Bunun yerine her kombinasyonda doğru olması gereken özellikleri kontrol ediyoruz: bir plan üretilir, onay e-postası müşterinin dilindedir, sipariş en geç üç iş günü içinde çıkar. Pairwise kombinasyonları tarar; her kombinasyonun doğru sonucunu ise önceki yazılardaki teknikler test eder.
failed Bookstore.Ordering.Tests.CheckoutPlannerPairwiseTests.Every_valid_pair_gets_a_plan(customer: Guest, payment: CreditCard, delivery: StorePickup, language: "en") (0ms)
Xunit.MicrosoftTestingPlatform.XunitException: System.InvalidOperationException : No confirmation template named 'store-pickup.en'.
Test run summary: Failed!
total: 11
failed: 1
succeeded: 10
skipped: 0
duration: 379ms
On bir satırdan biri kırmızı ve kırmızı olan satır tam olarak eksik çifti taşıyor. Düzeltme, eksik template'i eklemek:
"store-pickup.tr",
"store-pickup.en",
Test run summary: Passed!
total: 131
failed: 0
succeeded: 131
skipped: 0
duration: 397ms
Tarayıcı Boyutu ve Playwright Project'leri
Unit seviyede 11 ile 54 arasındaki fark birkaç milisaniye. Pairwise'ın asıl kazancı her satırın pahalı olduğu yerde: tarayıcıda yürüyen E2E testlerinde. Modelin başına bir parametre daha ekliyoruz:
# Bookstore checkout E2E: tarayıcı, müşteri tipi, ödeme, teslimat ve dil
Browser: Chromium, Firefox, WebKit
Customer: Individual, Institutional, Guest
Constraint'ler aynı. Geçerli tam kombinasyon 90 satır, pairwise 13 satır. Playwright Test'te tarayıcılar genellikle playwright.config.ts içindeki project'lerle tanımlanır ve her test her project'te bir kez koşar; ayrıntısı Integration ve E2E Test serisinin "Playwright'a Giriş" yazısında. İki yol var. Birincisi tarayıcıyı modelden çıkarmak: 11 satır üç project'te koşar, 33 test olur ve her satır her tarayıcıda denenir. İkincisi tarayıcıyı modelde tutmak: 13 satır üretilir ve her project yalnızca kendi tarayıcısının satırlarını çalıştırır; testler satırdaki tarayıcı adını project adıyla karşılaştırıp diğerlerini atlar. İkinci yol daha az test koşar ama tarayıcıyı diğer parametrelerle eşit tutar: Firefox'ta açık hesapla ödeme denenir, WebKit'te denenmeyebilir. Tarayıcı farkının risk olduğu yerlerde (ödeme sayfasındaki 3D Secure yönlendirmesi gibi) birinci yolu, tarayıcının sıradan bir parametre olduğu yerlerde ikinciyi seçmek mantıklı.
Üretimde Dikkat
Model dosyasını review'a sok. Constraint'ler iş kuralıdır; yanlış bir constraint geçerli bir kombinasyonu sessizce test dışında bırakır. "Kurumsal siparişler mağazadan teslim alınmaz" satırı bir gün değişirse model de değişmeli, yoksa yeni geçerli kombinasyonlar hiç test edilmez.
Üretilen dosyayı commit'le ve modeli değiştirince yeniden üret. Testin girdisi bir build sırasında anlık üretilirse, PICT sürümü ya da seed değiştiğinde test seti de sessizce değişir. Bir hatayı yakalamış satırları /e ile sabitlemek, yeni üretimde onları kaybetmeni önler.
Değer listelerini equivalence partitioning ile kur. PICT değerleri kendisi bulmaz; modeldeki her değer bir partition'ın temsilcisidir. Sepet tutarı gibi sürekli bir parametreyi modele katacaksan, üçüncü yazıdaki sınır değerlerinden birkaçını değer olarak yaz.
Negatif testleri ayrı tut. PICT bir değerin önüne ~ konunca onu geçersiz sayar ve her satıra en fazla bir geçersiz değer koyar; ikinci yazıdaki maskeleme sorununu araç seviyesinde çözer. Yine de geçersiz değerlerin beklenen sonucu farklı olduğu için onları ayrı bir modelde ve ayrı bir testte tutmak daha okunaklı.
Sık Düşülen Tuzaklar
Pairwise'ı kritik kuralların tam testinin yerine koymak. Pairwise her çifti bir kez dener; para, yetki ve iade gibi kurallarda bir kez yetmez. Kargo ücretinin decision table'ı, kurumsal indirimin sınır değerleri yerinde kalır. Pairwise bunların üzerine, kimsenin kural olarak yazmadığı kesişimleri taramak için eklenir.
Constraint yazmayıp çıktıyı elle filtrelemek. İmkânsız satırları sonradan silmek, o satırlarda kapsanan geçerli çiftleri de siler. Elde kalan set artık pairwise değildir ve bunu hiçbir şey söylemez.
Her satır için ayrı beklenen değer yazmaya çalışmak. Kombinasyon sayısı arttıkça bu, decision table'ı test kodunda yeniden yazmaya dönüşür. Kombinatoryal testin oracle'ı genellikle her satırda geçerli olan özelliklerdir.
Pairwise'ı garanti sanmak. Üç parametrenin birlikte tetiklediği bir hata pairwise setinde yakalanmayabilir. Riskli parametreleri bir sub-model'de 3-wise'a çıkarmak ya da kritik üçlüleri seed satırı olarak eklemek mümkün.
Küçük Bir Deneme
★☆☆ Modeli /o:3 ile çalıştır. Kaç satır çıkıyor? Mağazadan teslim, İngilizce ve havale üçlüsü hangi satırda?
★★☆ Modele Coupon: None, FreeShipping, Percent10 parametresini ve "havale ile ödenen siparişte express seçilemez" constraint'ini ekle. Pairwise satır sayısı ne kadar değişiyor? Tam kombinasyon sayısı ne kadar değişiyor?
★★★ CheckoutPlanner'a "havale ile ödenen kurumsal express siparişte dispatch dört gün olur" diye bir hata ekle. Bu hata kaç parametrenin birlikte gelmesine bağlı? Pairwise seti yakalıyor mu? Yakalamıyorsa modele bir sub-model ya da seed satırı ekleyerek yakalat.
Şimdiye kadar test case'leri hep requirement'tan ve modellerden çıkardık. Bir sonraki yazıda yön değiştiriyoruz: önce koda bakıp branch coverage raporundan atlanan dalları bulacağız, sonra deneyime dayalı tekniklere geçeceğiz: error guessing, checklist ve checkout için bir saatlik exploratory testing session'ı.