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

Unit Test'te "Unit" Ne Demek? Classical ve London School, İyi Testin Dört Özelliği

Mert Özen 8 Kas 2026 11 dk 3 görüntülenme
Unit Test'te "Unit" Ne Demek? Classical ve London School, İyi Testin Dört Özelliği

Unit, bir method ya da class değil bir davranıştır; test double'ı yalnız sistem sınırında kullanan classical school, her collaborator'ı mock'layan London school'dan daha refactoring'e dayanıklı testler üretir. Solitary ve sociable test ayrımı, Khorikov'un iyi testin dört özelliği ve davranışı aynı bırakan bir değişikliğin mock'lu testi nasıl kırdığı gerçek çıktıyla.

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

Önce Tahmin Et

Bookstore'un sipariş akışında OrderService iki iş yapıyor: her satır için stok var mı diye bakıyor, varsa ödeme sistemine ara toplamı çekiyor. Bu class için iki farklı ekipten iki farklı test yazılmış. Önce test edilen kod:

namespace Bookstore.Ordering;

public interface IInventory
{
    bool IsInStock(string isbn, int quantity);
    int Available(string isbn);
}

public sealed class Inventory : IInventory
{
    private readonly Dictionary<string, int> stock = [];

    public void Receive(string isbn, int quantity) => stock[isbn] = Available(isbn) + quantity;
    public int Available(string isbn) => stock.GetValueOrDefault(isbn);
    public bool IsInStock(string isbn, int quantity) => Available(isbn) >= quantity;
}

public interface IPaymentGateway
{
    Task<bool> ChargeAsync(Guid orderId, decimal amount, CancellationToken ct);
}

public enum PlaceOrderResult { Placed, OutOfStock, PaymentDeclined }

public sealed class OrderService(IInventory inventory, IPaymentGateway payments)
{
    public async Task<PlaceOrderResult> PlaceAsync(Order order, CancellationToken ct = default)
    {
        foreach (OrderLine line in order.Lines)
        {
            if (!inventory.IsInStock(line.Book.Isbn, line.Quantity))
            {
                return PlaceOrderResult.OutOfStock;
            }
        }

        bool charged = await payments.ChargeAsync(order.Id, order.Subtotal, ct);
        return charged ? PlaceOrderResult.Placed : PlaceOrderResult.PaymentDeclined;
    }
}

Birinci test, her collaborator'ı NSubstitute ile değiştiriyor ve hangi method'un hangi argümanla çağrıldığını doğruluyor:

public sealed class OrderServiceLondonTests
{
    private static readonly Book Ddd = new("978-0321125217", "Domain-Driven Design", 55m);

    [Fact]
    public async Task PlaceAsync_ChecksStockAndChargesSubtotal()
    {
        IInventory inventory = Substitute.For<IInventory>();
        inventory.IsInStock(Ddd.Isbn, 2).Returns(true);
        IPaymentGateway payments = Substitute.For<IPaymentGateway>();
        payments.ChargeAsync(Arg.Any<Guid>(), Arg.Any<decimal>(), Arg.Any<CancellationToken>()).Returns(true);
        Order order = new(Guid.NewGuid(), "ada@example.com");
        order.AddLine(Ddd, 2);
        OrderService sut = new(inventory, payments);

        PlaceOrderResult result = await sut.PlaceAsync(order, TestContext.Current.CancellationToken);

        Assert.Equal(PlaceOrderResult.Placed, result);
        inventory.Received(1).IsInStock(Ddd.Isbn, 2);
        await payments.Received(1).ChargeAsync(order.Id, 110m, Arg.Any<CancellationToken>());
    }
}

İkinci test gerçek Inventory class'ını kullanıyor; yalnız ödeme sistemini, elle yazılmış küçük bir fake ile değiştiriyor:

public sealed class OrderServiceClassicalTests
{
    private static readonly Book Ddd = new("978-0321125217", "Domain-Driven Design", 55m);

    [Fact]
    public async Task Order_with_enough_stock_is_placed_and_charged_once()
    {
        Inventory inventory = new();
        inventory.Receive(Ddd.Isbn, 5);
        FakePaymentGateway payments = new(approve: true);
        Order order = new(Guid.NewGuid(), "ada@example.com");
        order.AddLine(Ddd, 2);
        OrderService sut = new(inventory, payments);

        PlaceOrderResult result = await sut.PlaceAsync(order, TestContext.Current.CancellationToken);

        Assert.Equal(PlaceOrderResult.Placed, result);
        Assert.Equal([(order.Id, 110m)], payments.Charges);
    }
}

// Ödeme sistem sınırında: elle yazılmış basit bir fake
public sealed class FakePaymentGateway(bool approve) : IPaymentGateway
{
    public List<(Guid OrderId, decimal Amount)> Charges { get; } = [];

    public Task<bool> ChargeAsync(Guid orderId, decimal amount, CancellationToken ct)
    {
        Charges.Add((orderId, amount));
        return Task.FromResult(approve);
    }
}

İkisi de yeşil. Sonra bir ekip arkadaşı, aynı kitabın siparişte iki ayrı satırda gelebileceğini fark ediyor ve stok kontrolünü değiştiriyor: satırları ISBN'e (International Standard Book Number) göre gruplayıp toplam adedi Available ile karşılaştırıyor. Tek satırlı bir sipariş için davranış birebir aynı kalıyor:

// Refactoring: aynı kitap iki satırda olabilir, önce adetleri topla
IEnumerable<(string Isbn, int Quantity)> required = order.Lines
    .GroupBy(l => l.Book.Isbn)
    .Select(g => (Isbn: g.Key, Quantity: g.Sum(l => l.Quantity)));

if (required.Any(r => inventory.Available(r.Isbn) < r.Quantity))
{
    return PlaceOrderResult.OutOfStock;
}

Hangi test kırmızıya döner? Birincisi mi, ikincisi mi, ikisi de mi, hiçbiri mi? Devam etmeden önce bir cevap seç.

Cevap: Davranış Aynı, Test Kırmızı

dotnet test çıktısı (dosya yollarını kısalttım):

failed Bookstore.Ordering.Tests.OrderServiceLondonTests.PlaceAsync_ChecksStockAndChargesSubtotal (28ms)
  from …/p01/Bookstore.Ordering.Tests/bin/Debug/net10.0/Bookstore.Ordering.Tests.dll (net10.0|arm64)
  Assert.Equal() Failure: Values differ
  Expected: Placed
  Actual:   OutOfStock
    at Bookstore.Ordering.Tests.OrderServiceLondonTests.PlaceAsync_ChecksStockAndChargesSubtotal() in …/OrderServiceTests.cs:23

Test run summary: Failed!
  total: 3
  failed: 1
  succeeded: 2
  skipped: 0
  duration: 323ms

Mock'lu test kırıldı, gerçek Inventory kullanan test yeşil kaldı. Kod hâlâ doğru çalışıyor: 5 kitaplık stoktan 2 kitap istenen bir sipariş yine onaylanıyor. Kırmızının nedeni şu: NSubstitute'un ürettiği IInventory, kurulmamış her çağrıya varsayılan değer döner. Test yalnız IsInStock'u ayarlamıştı; yeni kod Available'ı çağırınca 0 aldı ve siparişi stok yok diye reddetti.

Testi "düzeltmenin" kolay yolu belli: Available için de bir Returns eklemek. Ama o zaman testteki Received(1).IsInStock(...) satırı kırılır, çünkü artık o method hiç çağrılmıyor. Yani test, OrderService'in ne yaptığını değil, nasıl yaptığını kilitlemiş. Her iç değişiklikte test de değişecek. Bu yazı, serinin geri kalanında sık sık döneceğimiz sorunun ilk örneği: bir testin neyi kontrol ettiği, ne kadar yaşayacağını belirler.

"Unit" Ne Demek? Method, Class, Davranış

Unit test'in herkesin üzerinde anlaştığı bir tanımı yok. Martin Fowler 2014'te yazdığı UnitTest yazısında bunu açıkça söyler: terim gevşek tanımlıdır ve insanlar onu olduğundan daha kesin sanır. Ortak noktalar şunlar: düşük seviyeye odaklanır, geliştirici kendi araçlarıyla yazar ve diğer test türlerinden belirgin şekilde hızlıdır.

"Unit"in büyüklüğü konusunda üç yaygın cevap var. Birincisi "bir method": her public method'a bir ya da birkaç test. İkincisi "bir class": her class'a bir test class'ı ve o class'ın tüm collaborator'ları test double ile değiştirilir. Üçüncüsü "bir davranış": bir iş kuralını ya da bir kullanım senaryosunu doğrulayan, gerekirse birkaç class'ı birlikte çalıştıran test. Fowler, object-oriented tasarımda genelde class'ın unit kabul edildiğini, ama kendisinin sık sık birbirine yakın birkaç class'ı tek unit gibi ele aldığını yazar; kararı ekibe bırakır.

Vladimir Khorikov'un Unit Testing Principles, Practices, and Patterns (Manning, 2020) kitabındaki tanım daha işe yarar bir çerçeve sunar. Bir unit test üç şeyi sağlar: küçük bir kod parçasını (bir unit of behavior) doğrular, hızlı çalışır ve diğer testlerden izole çalışır. Asıl tartışma "izole" kelimesinin ne demek olduğu üzerinedir: test edilen kod collaborator'larından mı izole edilmeli, yoksa testler birbirinden mi?

Bu seride kullanacağımız cevap şu: unit, bir davranıştır. Bir class o davranışı tek başına taşıyorsa unit bir class'tır; davranış Order, Inventory ve OrderService'e dağılmışsa, üçünü birlikte çalıştıran test de unit test'tir. Bu tercihin bedeli ve faydası aşağıdaki iki ayrımda netleşiyor.

Solitary ve Sociable Testler

Fowler'ın yazısında öne çıkardığı ayrım Jay Fields'a ait (Working Effectively with Unit Tests kitabının yazarı). Solitary test, test edilen unit'in bütün collaborator'larını test double ile değiştirir; unit kendi başına, yalnız kalır. Sociable test, collaborator'ların gerçeklerini kullanır ve onların doğru çalıştığını varsayar.

Örneğimizde OrderServiceLondonTests solitary: hem IInventory hem IPaymentGateway sahte. OrderServiceClassicalTests sociable: Inventory gerçek, yalnız ödeme sahte. Fowler'ın hatırlattığı ilginç bir ayrıntı var: xUnit ailesinin ilk yıllarında yazılan testlerin çoğu sociable'dı ve kimse onlara "unit test değil" demiyordu. Bir test hâlâ tek bir unit'in davranışını hedefliyorsa, gerçek collaborator kullanması onu integration test yapmaz.

Sociable testin bilinen bir maliyeti var: Inventory'de bir bug olursa, onu kullanan her testin kırmızıya dönmesi. Bir hata yüzlerce testi aynı anda kırabilir ve kaynağını bulmak biraz zaman alır. Solitary testte kırmızı test doğrudan suçluyu gösterir. Ama bu yazının başındaki çıktı solitary testin bedelini de gösteriyor: davranışı bozmayan bir değişiklik testi kırdı.

Classical (Detroit) ve London (Mockist) School

Solitary ile sociable arasındaki tercih, unit testing dünyasında iki "okul" olarak bilinir. Classical school (Detroit ya da Chicago school de denir), Kent Beck'in Extreme Programming (XP) çevresinden gelir; Beck'in Test-Driven Development by Example (2002) kitabı bu üslubu yansıtır. London school ise Londra'daki XP topluluğundan çıktı ve Steve Freeman ile Nat Pryce'ın Growing Object-Oriented Software, Guided by Tests (GOOS, 2009) kitabıyla yerleşti. Fowler'ın "Mocks Aren't Stubs" yazısı ikinci gruba "mockist" der.

İki okulun farkı birkaç maddede toplanabilir:

SoruClassical (Detroit)London (mockist)
Unit nedir?Bir davranış; bir ya da birkaç classBir class
Neyi test double ile değiştirir?Yalnız paylaşılan ya da yavaş bağımlılıkları (veritabanı, ödeme, e-posta)Değişebilir state taşıyan her collaborator'ı
Neyi doğrular?Sonucu ve gözlenebilir state'iCollaborator'larla yapılan konuşmayı
Bug oluncaBirden çok test kırılabilirGenelde tek test kırılır
Refactoring oluncaDavranış aynıysa testler yeşil kalırİç çağrılar değiştiyse testler kırılır

London school'un güçlü yanını haksızlık etmeden söylemek gerekiyor: GOOS'ta mock'lar bir tasarım aracıdır. Testi yazarken collaborator'ın interface'ini keşfedersin, rol tanımlarını testten başlayarak çıkarırsın. Büyük ve iç içe object grafiklerinde neyin bozulduğunu tek kırmızı testle görmek de gerçek bir avantaj. Sorun, bu üslubun her collaborator'a uygulanmasıyla başlıyor. Inventory gibi process içinde çalışan, kendi kodumuz olan bir collaborator'ı mock'ladığında, iki class arasındaki konuşmayı sözleşmeye dönüştürmüş olursun ve o konuşma her refactoring'de değişir.

Bu seride classical school'a yakın duracağız: test double'ı yalnız sistem sınırında, gerçekten yavaş ya da kontrol edilemeyen bağımlılıklar için kullanacağız. Hangi bağımlılığın mock'lanacağı sorusunun ayrıntısı 08. yazının konusu.

İyi Bir Testin Dört Özelliği

Khorikov, iyi bir unit test'i dört özellikle tanımlar. Bu dört özellik, "bu test iyi mi?" sorusunu zevk meselesi olmaktan çıkarıyor:

  • Regresyona karşı koruma: kod bozulduğunda testin kırmızıya dönme ihtimali. Ne kadar çok ve ne kadar önemli kodu çalıştırıyorsa o kadar yüksek.
  • Refactoring'e dayanıklılık: davranış bozulmadan yapılan iç değişikliklerde testin yeşil kalması. Bu özelliği düşük olan test false positive, yani yanlış alarm üretir.
  • Hızlı geri bildirim: testin ne kadar çabuk çalıştığı. Milisaniyede biten test her kayıtta koşar; dakikalar süren test bir kenara itilir.
  • Bakım kolaylığı: testi okumanın, anlamanın ve çalıştırmanın maliyeti. Uzun setup zinciri ve dış süreç gerektiren testler bu özellikte puan kaybeder.

Bu dördü aynı anda en üst seviyeye çıkarılamaz. Uçtan uca (E2E, end-to-end) bir test regresyona karşı çok iyi koruma sağlar ve refactoring'e dayanıklıdır, ama yavaştır. Getter'ı kontrol eden önemsiz bir test hızlı ve dayanıklıdır, ama neredeyse hiçbir şeyi korumaz. Khorikov'un önemli gözlemi, refactoring'e dayanıklılığın pazarlık konusu olmaması: ya vardır ya yoktur, ve olmadığında ekip bir süre sonra kırmızı testleri ciddiye almayı bırakır. Geri kalan üç özellik arasında bilinçli bir denge kurulur.

Başlangıçtaki iki testi bu gözle değerlendirelim. İkisinin de regresyona karşı koruması aynı: aynı kodu çalıştırıp aynı sonucu kontrol ediyorlar. İkisi de hızlı. Ama London testinin refactoring'e dayanıklılığı yok; ilk iç değişiklikte yanlış alarm verdi. Bakım açısından da yedi satırlık setup, beş satırlık setup'a karşı kaybediyor.

Microsoft'un "Best practices for writing unit tests" dokümanı aynı fikri farklı kelimelerle verir: iyi bir test hızlı (fast), izole (isolated), tekrarlanabilir (repeatable), kendi kendini doğrulayan (self-checking) ve zamanında yazılmış (timely) olmalıdır. Hangi testin hangi seviyede yazılacağına dair stratejik soruları, yani test pyramid'i ve coverage'ın anlamını Test Stratejisi serisi ele alıyor; bu seri o kararların unit test tarafını işliyor.

Seri Boyunca Bookstore

Serinin bütün örnekleri sitedeki Bookstore uygulamasının sipariş tarafında geçiyor. Kullanacağımız parçalar şunlar:

Order, OrderLine ve Book: siparişin kendisi; ara toplam, satır ekleme kuralları. Saf domain kodu, test double gerektirmez. İndirim kuralları: eşik tutarı, sadakat indirimi, kupon. 04. yazıda sınır değerleriyle, 09. yazıda saf fonksiyon olarak test edeceğiz. Inventory: stok seviyesi ve rezervasyon. Process içinde çalışan kendi kodumuz. OrderService: sipariş akışını yöneten orchestration katmanı. Dış servisler: IPaymentGateway (ödeme), IEmailSender (e-posta), IShippingService (kargo). Bunlar sistem sınırında, test double'ın gerçekten gerektiği yerler.

Seri, ASP.NET Core Web API (Application Programming Interface) serisinin 29. yazısındaki xUnit ve Moq temelini ve C# İleri serisinin 14. yazısındaki [Fact]/[Theory] başlangıcını tekrar etmiyor; onları xUnit.net v3 ve 4.0 ile derinleştiriyor. Bir sonraki yazı test projesini sıfırdan kuruyor.

Üretimde Dikkat

Bir test suite'inin sağlığını en iyi gösteren sinyal, ekip refactoring yaptığında ne olduğudur. Kod değişikliği küçük, ama değişen test dosyası sayısı büyükse, testler davranışı değil implementation'ı kilitlemiş demektir. Pull request'lerde "production kodunda 12 satır, testlerde 140 satır değişti" oranını görmeye başladığında bu yazının konusu sana da gelmiş olur.

İkinci sinyal kırmızıya verilen tepki. Kırmızı test görünce ilk refleks "test bozuk, güncelleyelim" ise, testler yanlış alarm üretiyordur. Yanlış alarm oranı arttıkça ekip gerçek alarmı da görmezden gelmeye başlar. Bir test suite'inin değeri, kırmızının ciddiye alınmasıyla ölçülür.

Üçüncüsü süre. Unit test suite'i birkaç bin test için birkaç saniyede bitmeli. Kent Beck'in kuralı olarak Fowler'ın aktardığı ölçü, commit suite'inin on dakikayı geçmemesi; unit testler bu bütçenin küçük bir kısmını kullanmalı. Yavaşlayan unit test'in nedeni genelde gizli bir I/O, yani gerçek dosya sistemi, ağ ya da veritabanı çağrısıdır.

Sık Düşülen Tuzaklar

Her class'a bir test class'ı refleksi. OrderService'e OrderServiceTests, Inventory'ye InventoryTests, OrderLine'a OrderLineTests... Bu yapı test dosyalarını class yapısına bağlar; bir class'ı ikiye böldüğünde ya da iki class'ı birleştirdiğinde testler de yeniden düzenlenmek zorunda kalır. Test class'larını davranışa göre gruplamak (sipariş verme, stok yetersizliği, ödeme reddi) daha uzun ömürlüdür. 03. yazıda bunu isimlendirmeyle birlikte ele alıyoruz.

Her collaborator'a bir mock refleksi. Constructor'da bir interface gördüğün an Substitute.For yazmak. Bu refleks, yukarıdaki kırmızı testi üreten şeyin ta kendisi. Önce "bu collaborator gerçek hâliyle testte kullanılabilir mi?" diye sor: process içinde mi, hızlı mı, deterministik mi? Üçüne de evet diyorsan gerçeğini kullan.

"Bu unit test değil, çünkü iki class çalışıyor" tartışması. Unit'in bir class olması gerektiği bir kural değil, bir okulun tercihidir. Sociable bir test hızlı, izole ve deterministik çalışıyorsa unit test'tir. İsim tartışmasına harcanan zamanı, testin dört özelliğinden hangisinde zayıf olduğunu konuşmaya harca.

Test sayısını kalite ölçüsü sanmak. 400 solitary test, 80 sociable testten daha fazla güven vermeyebilir; tersine, her refactoring'de 400 testin bir kısmını güncellemek zorunda kalırsın. Önemli olan sayı değil, her testin dört özellikteki dengesidir.

Küçük Bir Deneme

★☆☆ OrderServiceLondonTests'i, refactoring sonrası kodla yeşile döndür ama hiçbir Received satırı kullanma. Testin regresyona karşı korumasından ne kaybettin, refactoring'e dayanıklılığından ne kazandın?

★★☆ Refactoring öncesi koda geri dön ve classical teste şu senaryoyu ekle: stokta 1 adet "Domain-Driven Design" var, sipariş aynı kitabı iki ayrı satırda birer adet istiyor. Eski kod bu siparişi kabul eder mi? Aynı testi refactoring sonrası kodla çalıştır.

★★★ Kendi projenden bir servis test class'ı seç. Her testi dört özelliğe göre 1-3 arası puanla ve en düşük puanı alan özelliği yaz. Refactoring'e dayanıklılığı düşük çıkan testlerde hangi collaborator'ın mock'u buna neden oluyor?

Bir sonraki yazıda bu testlerin üzerinde koştuğu zemini kuruyoruz: xUnit.net v3 template'i, executable test projesi, Microsoft Testing Platform ile dotnet test ve 4.0'ın getirdiği değişiklikler.