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

Test Case Tasarımı Nedir? Test Koşulu, Test Case, Coverage Hedefi ve Teknik Aileleri

Mert Özen 15 Kas 2026 13 dk 4 görüntülenme
Test Case Tasarımı Nedir? Test Koşulu, Test Case, Coverage Hedefi ve Teknik Aileleri

Test case tasarımı, test case'leri sezgiyle değil requirement'tan türetmektir: test basis'ten test condition'a, coverage item'a, test case'e ve test verisine giden bir zincir. Bookstore'un kurumsal sipariş kuralında aynı senaryoyu deneyen beş yeşil test iki hatayı kaçırdı; kuraldan türetilen sekiz test ikisini de yakaladı. ISTQB CTFL v4.0.1'e göre black-box, white-box, experience-based teknikler ve collaboration-based yaklaşımlar.

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'a (bir online kitapçı) kurumsal sipariş geliyor. Requirements Analysis serisinde kurallar netleşmişti: kurumsal sipariş toplamda en az 20 adet içerir, kurumsal indirim liste fiyatından yüzde 10'dur, indirimler üst üste binmez ve büyüğü uygulanır, her satır ayrı yuvarlanır ve yarım kuruş yukarı gider. Emre kodu yazmış:

namespace Bookstore.Ordering;

public sealed record OrderLine(string Isbn, decimal ListPrice, int Quantity, decimal CampaignRate = 0m);

public sealed record PricedLine(string Isbn, int Quantity, decimal AppliedRate, decimal Total);

public abstract record PricingResult
{
    public sealed record Priced(IReadOnlyList<PricedLine> Lines, decimal Total) : PricingResult;

    public sealed record Rejected(string Reason) : PricingResult;
}

public static class InstitutionalPricing
{
    public const int MinimumQuantity = 20;
    public const decimal InstitutionalRate = 0.10m;

    public static PricingResult Price(IReadOnlyList<OrderLine> lines)
    {
        int totalQuantity = lines.Sum(line => line.Quantity);
        if (lines.Any(line => line.Quantity < MinimumQuantity))
        {
            return new PricingResult.Rejected(
                $"An institutional order needs at least {MinimumQuantity} items; this order has {totalQuantity}.");
        }

        List<PricedLine> priced = [.. lines.Select(PriceLine)];
        return new PricingResult.Priced(priced, priced.Sum(line => line.Total));
    }

    private static PricedLine PriceLine(OrderLine line)
    {
        decimal rate = InstitutionalRate;
        decimal total = Math.Round(line.ListPrice * line.Quantity * (1 - rate), 2, MidpointRounding.AwayFromZero);
        return new PricedLine(line.Isbn, line.Quantity, rate, total);
    }
}

Testleri de yazmış. Beş satır, beş farklı adet; sayı az değil:

namespace Bookstore.Ordering.Tests;

public sealed class InstitutionalPricingTests
{
    [Theory]
    [InlineData(25)]
    [InlineData(30)]
    [InlineData(40)]
    [InlineData(50)]
    [InlineData(100)]
    public void Institutional_discount_is_ten_percent(int quantity)
    {
        PricingResult result = InstitutionalPricing.Price([new OrderLine("9780441172719", 250m, quantity)]);

        PricingResult.Priced priced = Assert.IsType<PricingResult.Priced>(result);
        Assert.Equal(250m * quantity * 0.9m, priced.Total);
    }
}
Test run summary: Passed!
  total: 5
  failed: 0
  succeeded: 5
  skipped: 0
  duration: 500ms

Hepsi yeşil. Kodda iki hata var. Okumadan önce tahmin et: bu beş testten kaçı bu hatalardan birini yakalayabilirdi? Hataları bulmak için kurallara tek tek bakman yeterli.

Cevap: Beş Test, Tek Senaryo

Cevap sıfır. İlk hata minimum kontrolünde: kural sipariş toplamına bakıyor, kod ise her satıra. 19 matematik ve 1 fen kitabı olan bir okul siparişi kurala göre geçmeli, bu kod onu reddediyor. İkinci hata indirimde: kampanya oranı hiç okunmuyor, yüzde 25 kampanyadaki bir kitap da yüzde 10 indirimle satılıyor.

Beş testin hiçbiri bu durumlara dokunmuyor, çünkü beşi de aynı soruyu soruyor: tek satırlı, kampanyasız, 20'nin üstünde bir sipariş yüzde 10 alıyor mu? 25 ile 100 arasındaki her sayı kodda aynı yoldan geçiyor. Beş test yazılmış ama test edilen tek bir senaryo var. Testler sezgiyle seçildiğinde sonuç çoğu zaman budur: aynı türden beş test, sınırda sıfır test, kuralın geri kalanında hiç test yok.

Aynı kural için testleri bu kez requirement'tan, kural kural türetiyoruz:

namespace Bookstore.Ordering.Tests;

public sealed class InstitutionalPricingTests
{
    private const string Dune = "9780441172719";
    private const string Emma = "9780141439587";
    private const string TheHobbit = "9780547928227";
    private const string Middlemarch = "9780141439549";

    // TC-01..TC-04: minimum 20 adet sipariş toplamına uygulanır
    public static TheoryData<int[], bool> MinimumQuantityCases => new()
    {
        { [20], true },
        { [19], false },
        { [19, 1], true },
        { [12, 7], false },
    };

    [Theory]
    [MemberData(nameof(MinimumQuantityCases))]
    public void Minimum_quantity_applies_to_the_order_total(int[] quantities, bool accepted)
    {
        OrderLine[] lines = [.. quantities.Select(quantity => new OrderLine(Dune, 250m, quantity))];

        PricingResult result = InstitutionalPricing.Price(lines);

        Assert.Equal(accepted, result is PricingResult.Priced);
    }

    // TC-05: reddedilen siparişte toplam adet söylenir
    [Fact]
    public void Rejection_states_the_order_total()
    {
        PricingResult result = InstitutionalPricing.Price(
            [new OrderLine(Dune, 250m, 12), new OrderLine(Emma, 84.95m, 7)]);

        PricingResult.Rejected rejected = Assert.IsType<PricingResult.Rejected>(result);
        Assert.Contains("this order has 19", rejected.Reason);
    }

    // TC-06, TC-07: indirimler üst üste binmez, büyüğü uygulanır
    public static TheoryData<string, decimal, decimal, int, decimal, decimal> LargerDiscountWinsCases => new()
    {
        { TheHobbit, 120m, 0.25m, 20, 0.25m, 1800.00m },
        { Middlemarch, 200m, 0.05m, 20, 0.10m, 3600.00m },
    };

    [Theory]
    [MemberData(nameof(LargerDiscountWinsCases))]
    public void Larger_discount_wins(
        string isbn, decimal listPrice, decimal campaignRate, int quantity, decimal expectedRate, decimal expectedTotal)
    {
        PricingResult result = InstitutionalPricing.Price([new OrderLine(isbn, listPrice, quantity, campaignRate)]);

        PricedLine line = Assert.Single(Assert.IsType<PricingResult.Priced>(result).Lines);
        Assert.Equal(expectedRate, line.AppliedRate);
        Assert.Equal(expectedTotal, line.Total);
    }

    // TC-08: her satır ayrı yuvarlanır, yarım kuruş yukarı
    [Fact]
    public void Each_line_is_rounded_half_up()
    {
        PricingResult result = InstitutionalPricing.Price(
            [new OrderLine(Emma, 84.95m, 3), new OrderLine(Dune, 250m, 17)]);

        PricingResult.Priced priced = Assert.IsType<PricingResult.Priced>(result);
        Assert.Equal([229.37m, 3825.00m], priced.Lines.Select(line => line.Total));
        Assert.Equal(4054.37m, priced.Total);
    }
}

Sekiz test, yani ilk hâlinden yalnız üç fazla. Çalıştırınca (stack trace'leri kısalttım):

failed Bookstore.Ordering.Tests.InstitutionalPricingTests.Larger_discount_wins(isbn: "9780547928227", listPrice: 120, campaignRate: 0.25, quantity: 20, expectedRate: 0.25, expectedTotal: 1800.00) (3ms)
  Assert.Equal() Failure: Values differ
  Expected: 0.25
  Actual:   0.10
failed Bookstore.Ordering.Tests.InstitutionalPricingTests.Each_line_is_rounded_half_up (0ms)
  Assert.IsType() Failure: Value is not the exact type
  Expected: typeof(Bookstore.Ordering.PricingResult+Priced)
  Actual:   typeof(Bookstore.Ordering.PricingResult+Rejected)
failed Bookstore.Ordering.Tests.InstitutionalPricingTests.Minimum_quantity_applies_to_the_order_total(quantities: [19, 1], accepted: True) (0ms)
  Assert.Equal() Failure: Values differ
  Expected: True
  Actual:   False

Test run summary: Failed!
  total: 8
  failed: 3
  succeeded: 5
  skipped: 0

İki hata da yakalandı. Üçüncü kırmızıya dikkat et: yuvarlama testi yuvarlama yüzünden kalmadı. 3 ve 17 adetlik iki satırdan oluşan sipariş, satır bazındaki minimum kontrolüne takılıp reddedildi. Yani bu kodda yuvarlama kuralı aslında hiç denenmemişti; bir hata başka bir kuralın testini kapatıyordu. Bir sonraki yazıda bu etkinin adını koyacağız.

Bu yazının konusu, ikinci test listesine nasıl varıldığı. Listeyi şans değil, bir yöntem üretti.

Test Basis'ten Test Verisine: Zincir

ISTQB (International Software Testing Qualifications Board; yazılım test sertifikalarını tanımlayan uluslararası kurul) CTFL (Certified Tester Foundation Level; temel seviye test sertifikası) syllabus'ının güncel sürümü v4.0.1, test analizi ile test tasarımını iki ayrı adım olarak tanımlar. Analiz "neyi test edeceğiz?" sorusunu, tasarım "nasıl test edeceğiz?" sorusunu cevaplar. Bu ayrım gündelik geliştirmede pek konuşulmaz, ama sezgiyle test yazmanın neden eksik kaldığını en iyi o gösterir. Zincir şöyle:

Test basis (test tabanı; testlerin türetildiği kaynak) burada acceptance criteria'dır: Requirements Analysis serisinin "Acceptance Criteria ve Example Mapping" yazısında masaya yatırılan kurallar ve örnekler. Kod değil, kural. Testi koddan türetirsen kodun yaptığını onaylarsın; kuraldan türetirsen kodun yapması gerekeni.

Test condition (test koşulu), test basis'ten çıkan ve test edilebilir olan bir parça: "minimum adet sipariş toplamına uygulanır", "büyük indirim kazanır". Her acceptance criterion genellikle bir ya da birkaç test condition verir. Syllabus da acceptance criteria'nın test condition olarak görülebileceğini söyler.

Coverage item (kapsanacak öğe), bir test condition'ın hangi parçalarının denenmesi gerektiğini somutlaştırır. Hangi coverage item'ların seçileceğini teknik belirler: equivalence partitioning'de her partition, boundary value analysis'te her sınır değeri, decision table'da her kural sütunu bir coverage item'dır. "Minimum adet" condition'ı için dört coverage item var: tek satırlı ve çok satırlı siparişte toplamın 20 ve üstünde ya da 20'nin altında olması. Değerleri sınırın iki yanından, 19 ve 20 olarak seçmek ise üçüncü yazının konusu olan boundary value analysis'in işi.

Test case (girdi, ön koşul ve beklenen sonuçtan oluşan tek bir deneme), bir veya birkaç coverage item'ı aynı anda kapsar. Test data ise o test case'i çalıştıran somut değerlerdir: 250 TL'lik Dune'dan 19 adet ve 84,95 TL'lik Emma'dan 1 adet.

Kurumsal sipariş kuralı için zincirin tamamı:

Test conditionCoverage itemTest case ve beklenen sonuç
Minimum adet sipariş toplamına uygulanırTek satır toplam 20 ve üstü, tek satır toplam 20'nin altı, çok satır toplam 20 ve üstü, çok satır toplam 20'nin altı[20] kabul, [19] red, [19, 1] kabul, [12, 7] red
Red mesajı toplam adedi söylerReddedilen çok satırlı sipariş12 + 7 adet: mesajda this order has 19
İndirimler üst üste binmez, büyüğü uygulanırKampanya yüzde 10'dan büyük, kampanya yüzde 10'dan küçükThe Hobbit yüzde 25: 1.800,00 TL; Middlemarch yüzde 5: 3.600,00 TL
Her satır ayrı, yarım kuruş yukarı yuvarlanırSonucu tam yarım kuruşa düşen satırEmma 84,95 × 3: 229,37 TL; sipariş 4.054,37 TL

Tablodaki her satırın bir gerekçesi var ve gerekçe koddan değil, kuraldan geliyor. Emre'nin beş testi bu tablonun ilk satırındaki tek bir coverage item'a düşüyor: "tek satır, toplam 20 ve üstü". Koddaki test adlarının ve yorumların bu tabloya geri götürmesi de bu yüzden önemli: TC-01'den TC-08'e kadar her test, hangi kuralı koruduğunu söylüyor. Bu, syllabus'ın traceability (izlenebilirlik; test ile requirement arasındaki bağın takip edilebilmesi) dediği şeyin en ucuz hâli.

Coverage Hedefi: Test Sayısı Değil, Kapsanan Item

"Kaç test yazmalıyım?" sorusunun cevabı bir sayı değil, bir oran. Her teknik kendi coverage item'larını tanımlar ve coverage, en az bir test case'in denediği item sayısının toplam item sayısına oranıdır. Sezgisel testlerin durumu bu ölçüyle açıkça görünür: tabloda dört test condition ve sekiz coverage item var; beş test yalnızca birine dokunuyor. Coverage yüzde 12,5. İkinci listede sekiz test sekiz item'ın hepsini kapsıyor: yüzde 100.

Bu oranı code coverage ile karıştırma. Code coverage, testlerin çalıştırdığı satır ya da branch oranını ölçer ve koda bakar; Test Stratejisi serisinin "Code Coverage Ne Söyler, Ne Söylemez" yazısında anlattığımız gibi yüksek bir sayı tek başına bir şey kanıtlamaz. Burada konuştuğumuz coverage ise requirement'tan türetilen item'lara bakar. Emre'nin beş testi hatalı kodun satırlarının çoğunu çalıştırıyordu; kodda yalnızca iki branch vardı ve testler bunlardan birinden geçiyordu. Eksik kalan şey koddaki bir satır değil, kodda hiç olmayan davranıştı: kampanya oranını okuyan bir satır yoktu ki çalışmasın. Koda bakan bir ölçü, yazılmamış kodu göremez.

Coverage hedefi her teknik için ayrıca konur ve "yüzde 100" her zaman makul hedef değildir. Para hesaplayan bir kural için decision table'ın her sütunu yüzde 100 kapsanır; bir raporun filtre kombinasyonları için kombinatoryal tekniklerle daha düşük ama bilinçli bir hedef seçilir. Önemli olan hedefin konmuş olması: "yeterince test yazdık mı?" sorusu ancak böyle cevaplanabilir.

Teknik Aileleri: Hangisi Hangi Soruyu Cevaplar?

Syllabus test tekniklerini üç aileye ayırır ve bunların yanında ayrı bir başlık olarak collaboration-based yaklaşımları anlatır. Bu serinin sekiz yazısı da bu ayrımı izliyor:

  • Black-box teknikler, diğer adıyla specification-based teknikler: "Kural ne diyor ve kuralın hangi bölgeleri farklı davranıyor?" Equivalence partitioning, boundary value analysis, decision table testing ve state transition testing. Test case'ler kodun nasıl yazıldığından bağımsızdır; implementasyon değişse de davranış aynıysa testler geçerli kalır.
  • White-box teknikler, diğer adıyla structure-based teknikler: "Kodun hangi dalı hiç çalışmadı?" Statement ve branch coverage. Koda bakarak test türetir; spec eksik ya da eskiyse bile kodun her yolunu görür, ama yazılmamış davranışı göremez.
  • Experience-based teknikler: "Bu tür kodda hatalar genelde nerede çıkar?" Error guessing, exploratory testing ve checklist-based testing. Gücü testi yapanın deneyimine bağlıdır; diğer iki ailenin kaçırdığını yakalar.
  • Collaboration-based yaklaşımlar: "Hatayı hiç yazmamak için kuralı birlikte nasıl netleştiririz?" Birlikte user story yazmak, acceptance criteria ve ATDD (Acceptance Test-Driven Development; kabul testlerinin koddan önce, ekipçe yazıldığı geliştirme). Diğerleri hatayı bulmaya, bu yaklaşım hatayı önlemeye odaklanır.

Kurumsal sipariş örneğinde bu ailelerin hepsinin izi var. Testleri kuraldan türettik: black-box. Minimum sınırı için 19 ve 20'yi seçtik: boundary value analysis. Kampanya ile kurumsal indirimin çakışmasını ayrı bir condition yaptık: küçük bir decision table. Yuvarlamayı ayrıca test etmek ise biraz error guessing; .NET'te Math.Round'un varsayılanının banker's rounding olduğunu bilen biri o satırı hiç unutmaz. Kuralların kendisi de Requirements Analysis'teki example mapping oturumundan, yani collaboration-based bir çalışmadan çıkmıştı.

Serinin planı da bu sırayla gidiyor: önce black-box teknikler (equivalence partitioning, boundary value analysis, decision table ve state transition), sonra pairwise ve Microsoft'un PICT (Pairwise Independent Combinatorial Testing; kombinasyon üreten komut satırı aracı) aracıyla kombinasyonlar, branch coverage'ı test tasarımında kullanmak, error guessing ve exploratory testing, son yazıda da bir story için hangi tekniğin seçileceği.

Bu sınıflandırma ISTQB'ye özgü değil. Glenford Myers'ın The Art of Software Testing kitabı (ilk baskı 1979, 3. baskı Badgett ve Sandler ile 2011) equivalence partitioning ve boundary value analysis'i black-box, coverage kriterlerini white-box başlığı altında anlatır. Lee Copeland'in A Practitioner's Guide to Software Test Design kitabı (2004) teknikleri tek tek, örnek problemler üzerinden işler. Bu serideki tanımlar syllabus'ı izliyor, ama örnekler ve kod Bookstore'dan.

Kodu Düzeltmek

İki düzeltme de tek satır. Minimum kontrolü toplama bakıyor, satır oranı kampanya ile kurumsal oranın büyüğü oluyor:

int totalQuantity = lines.Sum(line => line.Quantity);
if (totalQuantity < MinimumQuantity)
{
    return new PricingResult.Rejected(
        $"An institutional order needs at least {MinimumQuantity} items; this order has {totalQuantity}.");
}
decimal rate = Math.Max(InstitutionalRate, line.CampaignRate);
Test run summary: Passed!
  total: 8
  failed: 0
  succeeded: 8
  skipped: 0
  duration: 276ms

Testler kuraldan türetildiği için implementasyon değişse de anlamlarını korur. Yarın biri InstitutionalPricing'i strategy'lere bölse, satırları paralel hesaplasa ya da kampanya oranını Catalog module'ünden okusa bile bu sekiz test aynı kalır. Sezgisel testlerin ise ikinci bir zaafı vardı: beklenen değeri 250m * quantity * 0.9m diye bir formülle hesaplıyordu. Formül koddaki formülün kopyası olduğunda test, kodla aynı yanlışı yapar.

Üretimde Dikkat

Test condition listesini bir yerde tut. Ayrı bir doküman gerekmiyor; test class'ının başındaki yorumlar, TC-01 gibi numaralar ya da test adları yeterli. Önemli olan, bir kural değiştiğinde hangi testlerin etkileneceğini bulabilmek. Hakan yarın "minimum 20 değil 25" dediğinde arayacağın şey "minimum" kelimesi geçen testler değil, o condition'ın test case'leri.

Production'da çıkan her hata bir soru sorar: hangi test condition eksikti? Hata kaydını kapatırken yalnızca hatayı yeniden üreten testi değil, eksik olan condition'ı ve onun diğer coverage item'larını da ekle. Kurumsal siparişte "çok satırlı sipariş" condition'ı baştan listede olsaydı minimum hatası code review'a gelmeden yakalanırdı.

Test tasarımı code review'un konusudur. Pull request'te "testler geçiyor mu?" sorusu kadar "bu testler hangi condition'ları kapsıyor ve hangisi eksik?" sorusu da sorulmalı. Sekiz testlik listeyi okumak, kodu okumaktan daha hızlıdır ve eksik kuralı daha kolay gösterir.

Test sayısını değil, kapsanan item'ı raporla. Sprint sonunda "120 yeni test" demek bir şey anlatmaz. "Kurumsal sipariş story'sinin dört condition'ı ve sekiz coverage item'ı yüzde 100 kapsandı" demek, neyin güvence altında olduğunu anlatır.

Sık Düşülen Tuzaklar

Test sayısını kaliteyle karıştırmak. Aynı partition'dan beş test, bir testten daha fazla güven vermez; yalnızca test süresini ve bakım maliyetini artırır. Yeni bir test yazmadan önce "bu test, mevcut testlerin hiçbirinin kapsamadığı hangi item'ı kapsıyor?" diye sor. Cevap yoksa test gereksizdir.

Teknik seçmeden önce requirement'ı netleştirmemek. "Minimum 20 adet" kuralı kitap başına mı, sipariş toplamına mı? Bu soru cevaplanmadan yazılan her test, iki yorumdan birini tahmin eder. Yanlış tahmin kodda ve testte aynıysa test yeşil kalır. Requirements Analysis serisinin "Acceptance Criteria ve Example Mapping" yazısındaki oturum tam bu soruyu masaya koymuştu; test tasarımı orada başlar, test projesinde değil.

Beklenen değeri koddan kopyalamak. Expected değer elle, kurala bakılarak hesaplanır: 84,95 × 3 = 254,85; yüzde 10 indirimle 229,365; yarım kuruş yukarı: 229,37. Bu sayıyı testte formülle üretmek, test etmek istediğin hesabı ikinci kez yazmaktır.

Yalnızca happy path'i (her şeyin beklendiği gibi gittiği akış) test etmek. Reddedilen sipariş, kampanyalı kitap ve yuvarlama sınırı happy path'te yoktur. Kuralların çoğu da tam bu kenarlarda yaşar.

Bir testin neden kırmızı olduğuna bakmamak. Yuvarlama testi bu yazıda yuvarlama yüzünden değil, minimum hatası yüzünden kaldı. Kırmızı testi "zaten bir şey bozuk" diye geçersen, düzeltmeden sonra yeşile dönen testin gerçekten kendi kuralını doğruladığından emin olamazsın.

Küçük Bir Deneme

★☆☆ Sezgisel beş testi düzeltilmiş koda karşı çalıştır ve hepsinin yine geçtiğini gör. Sonra MinimumQuantity'yi 25 yap: hangi testler kırmızıya döner, hangileri yeşil kalır? Sonucu bu yazıdaki coverage tablosuyla karşılaştır.

★★☆ Kurala yeni bir cümle ekle: "Kurumsal indirim yalnızca basılı kitaplara uygulanır, e-kitaplar liste fiyatından satılır." Önce test condition'ları ve coverage item'ları yaz, sonra testleri ve kodu. Coverage tablosuna kaç satır eklendi?

★★★ InstitutionalPricing.Price'a boş bir liste ve adedi sıfır olan bir satır gönder. Requirement bu durumlar için bir şey söylüyor mu? Söylemiyorsa Selin'e soracağın soruyu tek cümleyle yaz ve cevaba göre bir test condition ekle.

Sonraki yazıda ilk tekniğe geçiyoruz: equivalence partitioning. Sipariş adedini, müşteri tipini ve ödeme yöntemini partition'lara bölüp geçerli ve geçersiz partition'ları ayıracak, iki geçersiz değeri aynı testte birleştirmenin bir hatayı nasıl sakladığını görecek ve partition tablosunu doğrudan TheoryData'ya çevireceğiz.