Mert Özen Her Satırda Daha İleri
Test ve Kalite TDD — #04

Outside-in TDD: Double Loop, Acceptance Test'ten Domain'e ve London ile Classical Okulu Pratikte

Mert Özen 14 Kas 2026 10 dk 1 görüntülenme
Outside-in TDD: Double Loop, Acceptance Test'ten Domain'e ve London ile Classical Okulu Pratikte

Outside-in TDD, özellik bitene kadar kırmızı kalan bir acceptance test'le dış döngüyü, onu geçirecek parçaları büyüten unit test döngüleriyle iç döngüyü kurar. POST /api/cart/{id}/coupon endpoint'ini WebApplicationFactory ile walking skeleton'dan başlatıp mock'larla collaborator keşfediyor, aynı handler'ı classical yolla da test ediyor ve davranışı değiştirmeyen bir refactor'da hangi testlerin kırıldığını gerçek çıktıyla karşılaştırıyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10, ASP.NET Core 10, xUnit.net v3 4.0.3, Microsoft.AspNetCore.Mvc.Testing 10.0.15, NSubstitute 6.2.0 ve Microsoft.Extensions.TimeProvider.Testing 10.11.0 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'a "sepete kupon uygula" endpoint'i ekledik ve arkasındaki ApplyCouponHandler için iki test class'ı yazdık. Birincisi handler'ın bütün collaborator'larını NSubstitute ile değiştiriyor ve SaveAsync'in çağrıldığını doğruluyor. İkincisi gerçek, in-memory repository'ler ve gerçek CouponValidator ile çalışıyor ve sonuçta sepetin hâline bakıyor. Her class'ta iki test var, dördü de yeşil.

Sonra performans ve tutarlılık için bir değişiklik geliyor: iki istek aynı sepete aynı anda kupon uygulamasın diye repository'ye satırı kilitleyerek okuyan FindForUpdateAsync ekleniyor ve handler FindAsync yerine onu çağırıyor. Endpoint'in dışarıdan davranışı birebir aynı. Dört testten kaçı kırmızıya döner? Hangileri? Yazının sonunda gerçek çıktıya bakacağız.

Double Loop: İki Döngü İç İçe

04-double-loop-tr.png

Önceki yazılarda TDD'yi (Test-Driven Development; önce testi, sonra kodu yazma yaklaşımı) tek bir class üzerinde, bağımlılığı olmayan kodla çalıştık. Gerçek bir özellik ise HTTP isteğinden başlar, birkaç katmandan geçer ve bir veri kaynağına dokunur. Outside-in TDD bu durumda iki döngü kurar. Dış döngüde özelliği kullanıcının gözünden tarif eden bir acceptance test yazılır. Bu test, özellik bitene kadar kırmızı kalır; saatler, bazen günler sürebilir. İç döngüde ise bildiğimiz red-green-refactor döner: dış testin geçmesi için gereken her parça, dakikalar süren unit test döngüleriyle büyütülür.

Bu yaklaşımı yaygınlaştıran kitap, Steve Freeman ve Nat Pryce'ın Growing Object-Oriented Software, Guided by Tests (GOOS, 2009) kitabı. Dış testin iki işi var. Birincisi yön göstermek: iç döngüde ne yazacağına, dış testin henüz geçmemesine bakarak karar verirsin. İkincisi bitişi tanımlamak: dış test yeşile döndüğünde özellik bitmiştir, fazlası spekülasyondur.

GOOS'un önerdiği başlangıç, walking skeleton. Alistair Cockburn'ün adını koyduğu bu fikir, gerçek işlevselliğin otomatik olarak build edilip uçtan uca test edilebilen en ince dilimini kurmak demek. Değeri, entegrasyon sorunlarını özellik geliştirmeden önce ortaya çıkarması: routing, serialization, dependency injection konfigürasyonu, test altyapısı ilk gün çalışır hâle gelir.

Dış Döngü: Kırmızı Kalan Acceptance Test

İlk testimiz endpoint'i dışarıdan çağırıyor. ASP.NET Core'u process içinde ayağa kaldırmak için WebApplicationFactory kullanıyoruz; kurulumunun ayrıntısı ASP.NET Core Web API serisinin integration test yazısında. .NET 10'da bir source generator Program class'ını otomatik olarak public yaptığı için, eskiden eklenen public partial class Program satırına gerek kalmadı.

public sealed class ApplyCouponAcceptanceTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task Applying_a_valid_coupon_returns_the_discounted_cart()
    {
        CancellationToken ct = TestContext.Current.CancellationToken;
        HttpClient client = factory.CreateClient();

        HttpResponseMessage response = await client.PostAsJsonAsync("/api/cart/42/coupon", new { Code = "BOOK10" }, ct);

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        CartSummary? cart = await response.Content.ReadFromJsonAsync<CartSummary>(ct);
        Assert.Equal(new CartSummary(42, 200m, 20m, 180m, "BOOK10"), cart);
    }
}

CartSummary record'unu ekleyip boş bir Program.cs ile çalıştırınca walking skeleton'ın ilk kırmızısı geliyor:

failed Bookstore.Api.Tests.ApplyCouponAcceptanceTests.Applying_a_valid_coupon_returns_the_discounted_cart (160ms)
  Assert.Equal() Failure: Values differ
  Expected: OK
  Actual:   NotFound

Bu kırmızı bize altyapının çalıştığını söylüyor: uygulama ayağa kalktı, istek gitti, cevap döndü. Eksik olan yalnız özelliğin kendisi. Testin arrange kısmının boş olduğuna dikkat et: sepet 42'nin ve BOOK10 kuponunun nereden geleceğini henüz bilmiyoruz. Bunu iç döngü söyleyecek.

İç Döngü, London Yolu: Mock'larla Collaborator Keşfi

Outside-in'in mock'lu (London ya da mockist) uygulamasında bir sonraki adım, endpoint'in hemen arkasındaki object'i tasarlamak. Handler'ın ne yapması gerektiğini biliyoruz, ama ona veriyi kimin vereceğini bilmiyoruz. Testi yazarken bu soruları cevaplıyoruz ve her cevap bir interface oluyor:

public sealed class ApplyCouponHandlerLondonTests
{
    private readonly ICartRepository carts = Substitute.For<ICartRepository>();
    private readonly ICouponRepository coupons = Substitute.For<ICouponRepository>();
    private readonly ICouponPolicy policy = Substitute.For<ICouponPolicy>();

    [Fact]
    public async Task Applies_an_accepted_coupon_and_saves_the_cart()
    {
        CancellationToken ct = TestContext.Current.CancellationToken;
        Cart cart = new(42, 200m);
        Coupon coupon = Coupon.Percentage("BOOK10", 10m);
        carts.FindAsync(42, ct).Returns(cart);
        coupons.FindByCodeAsync("BOOK10", ct).Returns(coupon);
        policy.Validate(coupon, 200m).Returns(CouponResult.Accepted(20m));
        ApplyCouponHandler handler = new(carts, coupons, policy);

        ApplyCouponOutcome outcome = await handler.HandleAsync(42, "BOOK10", ct);

        Assert.Equal(new ApplyCouponOutcome.Applied(new CartSummary(42, 200m, 20m, 180m, "BOOK10")), outcome);
        await carts.Received(1).SaveAsync(cart, ct);
    }

    // İkinci test: reddedilen kuponda SaveAsync hiç çağrılmaz (DidNotReceive)
}

Derleyici üç interface'in yokluğunu raporluyor: ICartRepository, ICouponRepository, ICouponPolicy. Bunlar GOOS'un "role interface" dediği şey: handler'ın ihtiyaç duyduğu rolü, handler'ın bakış açısından tanımlayan küçük interface'ler. Repository'ler doğal roller; veri bir veritabanından gelecek ve o sınırda bir soyutlama zaten istiyoruz. ICouponPolicy ise farklı: önceki yazılarda test-first büyüttüğümüz CouponValidator'ın önüne konmuş bir rol. Bunu aklımızda tutalım.

Interface'leri ve NotFound dönen boş bir handler'ı yazınca iki London testi doğru nedenle kırmızı: Expected: Applied { ... }, Actual: NotFound { }. Handler'ın implementasyonu:

public sealed class ApplyCouponHandler(ICartRepository carts, ICouponRepository coupons, ICouponPolicy policy)
{
    public async Task<ApplyCouponOutcome> HandleAsync(int cartId, string code, CancellationToken ct)
    {
        Cart? cart = await carts.FindAsync(cartId, ct);
        Coupon? coupon = await coupons.FindByCodeAsync(code, ct);
        if (cart is null || coupon is null)
        {
            return new ApplyCouponOutcome.NotFound();
        }

        CouponResult result = policy.Validate(coupon, cart.Subtotal);
        if (result.Rejection is { } reason)
        {
            return new ApplyCouponOutcome.Rejected(reason);
        }

        cart.ApplyCoupon(coupon.Code, result.Discount);
        await carts.SaveAsync(cart, ct);
        return new ApplyCouponOutcome.Applied(cart.ToSummary());
    }
}

İki London testi yeşil, acceptance test hâlâ kırmızı. Dış döngünün bize söylediği: rollerin gerçek implementasyonları ve endpoint eksik. Sırayla kapatıyoruz. ICouponPolicy için CouponValidator'a delege eden tek satırlık bir adapter yazıyoruz. Repository'ler için şimdilik in-memory implementasyonlar; gerçek veritabanıyla çalışan sürüm ve Testcontainers ilerideki Integration ve E2E (end-to-end) Test serisinin konusu. Son olarak endpoint:

app.MapPost("/api/cart/{id:int}/coupon", async (int id, ApplyCouponRequest request, ApplyCouponHandler handler, CancellationToken ct) =>
    await handler.HandleAsync(id, request.Code, ct) switch
    {
        ApplyCouponOutcome.Applied applied => Results.Ok(applied.Cart),
        ApplyCouponOutcome.Rejected rejected => Results.Problem(
            title: "Coupon rejected", detail: rejected.Reason.ToString(), statusCode: StatusCodes.Status422UnprocessableEntity),
        _ => Results.NotFound()
    });

Artık acceptance test'in arrange kısmını yazabiliyoruz, çünkü verinin nereye konacağını iç döngü belirledi:

CancellationToken ct = TestContext.Current.CancellationToken;
await factory.Services.GetRequiredService<ICartRepository>().SaveAsync(new Cart(42, 200m), ct);
factory.Services.GetRequiredService<InMemoryCouponRepository>().Add(Coupon.Percentage("BOOK10", 10m));
HttpClient client = factory.CreateClient();
Test run summary: Passed!
  total: 3
  failed: 0
  succeeded: 3
  duration: 562ms

Dış döngü kapandı. Bir sonraki acceptance senaryosu (süresi geçmiş kupon için 422) yeni bir dış döngü başlatır.

Aynı Özellik, Inside-out: Classical Yol

Classical (Detroit) okulunda aynı özellik genelde içeriden dışarı büyür. Önce domain: CouponValidator zaten önceki iki yazıda test-first yazıldı. Sonra handler, gerçek collaborator'larıyla. Bu okul test double'ı yalnız gerçekten yavaş ya da kontrol edilemeyen sınırlarda kullanır; burada o sınır veritabanı ve onun yerine kendi yazdığımız in-memory fake'ler var. En son endpoint ve acceptance test gelir.

public sealed class ApplyCouponHandlerClassicalTests
{
    private readonly InMemoryCartRepository carts = new();
    private readonly InMemoryCouponRepository coupons = new();

    private ApplyCouponHandler CreateHandler()
    {
        CouponValidator validator = new(new FakeTimeProvider(new DateTimeOffset(2026, 11, 14, 9, 0, 0, TimeSpan.Zero)));
        return new ApplyCouponHandler(carts, coupons, new CouponValidatorPolicy(validator));
    }

    [Fact]
    public async Task Rejected_coupon_leaves_the_cart_unchanged()
    {
        CancellationToken ct = TestContext.Current.CancellationToken;
        await carts.SaveAsync(new Cart(42, 100m), ct);
        coupons.Add(Coupon.Percentage("BOOK10", 10m) with { MinimumSubtotal = 150m });

        ApplyCouponOutcome outcome = await CreateHandler().HandleAsync(42, "BOOK10", ct);

        Assert.Equal(new ApplyCouponOutcome.Rejected(CouponRejection.BelowMinimumSubtotal), outcome);
        Cart? saved = await carts.FindAsync(42, ct);
        Assert.Null(saved?.CouponCode);
    }

    // İlk test: kabul edilen kupon, kaydedilen sepette görünür
}

İki testin farkı neyi doğruladıklarında. London testi, handler'ın collaborator'larıyla nasıl konuştuğunu kilitliyor: hangi method, hangi argümanla, kaç kez. Classical test ise sonucu ve gözlenebilir state'i doğruluyor: kupon reddedildiyse kayıtlı sepette kupon yok. London testi reddedilen kuponun minimum tutar yüzünden reddedildiğini bilmiyor bile; o kararı stub'a Returns ile söyletiyor. Classical test ise gerçek CouponValidator'ı çalıştırıyor.

Kırılganlığı Ölçmek: Davranışı Değiştirmeyen Bir Refactor

Şimdi "Önce Tahmin Et"teki değişikliği yapıyoruz. ICartRepository'ye FindForUpdateAsync ekleniyor; in-memory implementasyonda FindAsync ile aynı işi yapıyor, gerçek repository'de satırı kilitleyerek okuyacak. Handler'da tek satır değişiyor:

Cart? cart = await carts.FindForUpdateAsync(cartId, ct);
failed Bookstore.Api.Tests.ApplyCouponHandlerLondonTests.Does_not_save_the_cart_when_the_coupon_is_rejected (31ms)
  Expected: Rejected { Reason = BelowMinimumSubtotal }
  Actual:   NotFound { }
failed Bookstore.Api.Tests.ApplyCouponHandlerLondonTests.Applies_an_accepted_coupon_and_saves_the_cart (1ms)
  Expected: Applied { Cart = CartSummary { Id = 42, Subtotal = 200, Discount = 20, Total = 180, CouponCode = BOOK10 } }
  Actual:   NotFound { }
Test run summary: Failed!
  total: 5
  failed: 2
  succeeded: 3

İki London testi kırmızı, iki classical test ve acceptance test yeşil. Neden? London testleri FindAsync'i stub'lamıştı. Handler artık FindForUpdateAsync'i çağırıyor; NSubstitute, kurulmamış bir çağrıya varsayılan değer, yani null döndürüyor ve handler sepeti bulamadığını sanıyor. Classical testlerde ise in-memory repository yeni method'u bir kez implement etti ve testlerin hiçbirine dokunmak gerekmedi.

Bu kırmızılar yanlış alarm: kod doğru çalışıyor, endpoint doğru cevap veriyor. Unit Test serisinin ilk yazısında aynı olguyu "refactoring'e dayanıklılık" adıyla görmüştük. Burada önemli olan, outside-in TDD'nin bu kırılganlığı kaçınılmaz kılmadığı: mock'u nerede kullandığın belirleyici.

Mock Bir Tasarım Aracıdır, Doğrulama Aracı Değil

GOOS'ta mock'ların asıl işi, object'ler arasındaki konuşmayı tasarlamak. Handler testini yazarken üç rol keşfettik ve bu keşif değerliydi: handler'ın veriye nasıl ulaştığı ilk günden bir interface arkasına girdi, veritabanı kararı ertelenebildi. Mock burada bir tasarım aracı olarak işini yaptı.

Sorun, keşif bittikten sonra mock'ların test suite'te kalıcı doğrulayıcı olarak yaşamaya devam etmesi. Pratikte işe yarayan denge şu: mock'u, sınırı keşfetmek için kullan; sınır netleşince o sınırın arkasını gerçek ya da gerçeğe yakın bir fake ile değiştir. Repository'ler için in-memory fake, ICouponPolicy için gerçek CouponValidator. Process içinde çalışan, kendi yazdığımız, deterministik bir domain service'i mock'lamak, Unit Test serisinin over-mocking yazısındaki uyarının tam karşılığı. Aynı yazıdaki "sahibi olmadığını mock'lama" kuralı da bu akışta yerini buluyor: handler, Entity Framework Core'un DbContext'ini değil, bizim tanımladığımız ICartRepository'yi görüyor. Mock'lanan şey her zaman bizim tanımladığımız rol; üçüncü parti API'ye uyum, o rolün gerçek implementasyonunun integration testlerinde doğrulanır.

Bu açıdan bakınca ICouponPolicy ve onun adapter'ı, mock'lu tasarımın bıraktığı fazladan bir katman. Inside-out yolda handler doğrudan CouponValidator'ı alırdı. Hangisinin doğru olduğu, policy'nin ileride gerçekten değişip değişmeyeceğine bağlı; birden çok kupon kaynağı ya da A/B testli kurallar geliyorsa rol interface'i yaşar, gelmiyorsa refactor adımında kaldırılır.

Üretimde Dikkat

Acceptance test'in seviyesi. Dış döngünün hızı, TDD ritmini belirler. Bu yazıdaki acceptance test process içinde koştu ve beş testin hepsi yarım saniyede bitti. Aynı testi tarayıcı üzerinden, UI otomasyonuyla yazsaydık her koşu saniyeler, belki dakikalar sürerdi ve dış döngü bir günlük beklemeye dönerdi. Dış testi mümkün olan en düşük ama kullanıcının gözünden anlamlı seviyede yaz: çoğu backend özelliği için bu, HTTP seviyesidir.

Acceptance test sayısı. Her davranış için bir acceptance test yazmak, test suite'ini yavaşlatır ve aynı şeyi iki kez test eder. Dış testler özelliğin ana yollarını, iç testler edge case'leri taşır. Kuponda yuvarlama ve İstanbul gece yarısı gibi ayrıntılar unit seviyesinde kalır; acceptance testler "kabul", "red", "bulunamadı" yollarını kapsar.

Paylaşılan state. IClassFixture aynı uygulama instance'ını class'taki testler arasında paylaşır. In-memory repository'lere farklı testlerden aynı sepet id'si yazılırsa testler birbirini etkiler. Her teste ayrı id ya da ayrı factory, paralel koşan testlerde sürprizleri önler.

Sık Düşülen Tuzaklar

Acceptance test'i UI üzerinden yazıp yavaş döngü kurmak. Dış testin her koşusu dakika sürerse, geliştirici onu koşmayı bırakır ve double loop tek döngüye iner. UI testleri gerekli, ama outside-in'in dış döngüsü olarak değil.

Her collaborator'ı mock'layıp implementasyonu test etmek. Handler'ın içindeki her çağrıyı Received ile doğrulamak, testi kodun bir kopyasına çevirir. Davranışı doğrulayan bir assertion (dönen sonuç, kaydedilen state) yeterliyse interaction doğrulaması ekleme. Interaction'ı yalnız davranışın kendisi olduğunda doğrula: e-posta gönderildi mi, ödeme sistemi çağrıldı mı.

Dış testi hiç kırmızı görmemek. Önce endpoint'i yazıp sonra acceptance test eklemek, testin gerçekten neyi koruduğunu belirsiz bırakır. Walking skeleton'ın 404 kırmızısı, altyapının doğru kurulduğunun da kanıtı.

İç döngüde dış testi unutmak. Unit testler yeşilken "bitti" demek kolay. Özellik, dış test yeşile döndüğünde biter; iç döngü dış testin söylemediği bir şeyi büyütüyorsa, spekülatif kod yazılıyordur.

Küçük Bir Deneme

★☆☆ London testlerini FindForUpdateAsync değişikliğine uyarla. Kaç satır değiştirdin? Aynı değişiklik on handler testinde olsaydı?

★★☆ Süresi geçmiş kupon için yeni bir acceptance test yaz: 422 ve problem detail'inde Expired. Zamanı WithWebHostBuilder ve ConfigureTestServices ile FakeTimeProvider'a bağla. İç döngüde yeni bir unit test gerekti mi, yoksa davranış zaten var mıydı? Hemen yeşil olduysa önceki yazıdaki bozma kontrolünü uygula.

★★★ Aynı endpoint'i bu kez inside-out geliştir: handler doğrudan CouponValidator alsın, ICouponPolicy hiç olmasın. İki yolun sonunda kaç interface, kaç test ve kaç satır kod oldu? Hangi yol, policy'nin değişeceği bir gelecek için daha hazırlıklı?

Bir sonraki yazıda aynı ritmi frontend'e taşıyoruz: Angular 22 ve Vitest ile önce saf TypeScript mantığını, sonra signal tabanlı bir component'i test-first geliştireceğiz.