Mert Özen Her Satırda Daha İleri
Test ve Kalite Test Stratejisi — #03

Neyi Hangi Seviyede Test Etmeli? Davranış, Sınırlar ve Mock'lanacak Bağımlılıklar

Mert Özen 8 Kas 2026 10 dk 4 görüntülenme
Neyi Hangi Seviyede Test Etmeli? Davranış, Sınırlar ve Mock'lanacak Bağımlılıklar

Domain mantığı yoğun unit test, orchestration kodu az sayıda integration test alır; trivial kod test edilmez, aşırı karmaşık kod önce refactor edilir. Testte kendi veritabanın (managed) gerçek, ödeme ve e-posta gibi unmanaged bağımlılıklar mock olur. Davranışı değişmeyen bir refactoring'de kırılan mock'lu testin gerçek çıktısı, risk tabanlı önceliklendirme ve tek sayfalık test stratejisi dokümanı.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.402), Entity Framework Core 10.0.14, xUnit.net v3 4.0.2 ve NSubstitute 6.2.0 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un OrderService'i stok kontrol ediyor, ödeme alıyor, stoğu düşürüyor ve onay e-postası gönderiyor. Bunun için iki test var. Birincisi her bağımlılığı mock'luyor: stok repository'si, ödeme service'i, e-posta service'i. İkincisi stoğu gerçek bir SQLite veritabanında tutuyor, yalnız ödeme ve e-postayı mock'luyor. İki sipariş aynı son kitabı satmasın diye stok düşüşünü tek bir atomik UPDATE'e çeviriyoruz; müşteri, ödeme sağlayıcısı ve veritabanı açısından hiçbir şey değişmiyor. Hangi test kırılır? Kırılan test bir hata mı yakaladı, yoksa yanlış alarm mı verdi?

Khorikov'un Kod Dörtgeni

Vladimir Khorikov (Unit Testing Principles, Practices, and Patterns kitabının yazarı, Manning, 2020), "neyi hangi seviyede test edeyim?" sorusunu kodu iki eksene yerleştirerek cevaplıyor. Dikey eksen kodun karmaşıklığı ya da domain için önemi: dallanma, hesap, iş kuralı. Yatay eksen collaborator sayısı: kodun konuştuğu başka nesneler ve sistemler.

03-code-quadrant-tr.png

Sol üst köşe domain model ve algoritmalar: karmaşık ama az collaborator'lı kod. Bookstore'da DiscountPolicy.RateFor tam burada; eşik, müşteri seviyesi ve kombinasyonları var ama veritabanı da ağ da yok. Bu kod yoğun unit test hak ediyor; bir önceki yazıda sekiz sınır değerini 51 milisaniyede doğrulamıştık.

Sağ alt köşe controller, yani orchestration kodu: kendisi basit ama çok collaborator'la konuşuyor. OrderService.PlaceAsync stoğu okuyor, ödemeyi çağırıyor, veritabanına yazıyor, e-posta gönderiyor; hesabı ise DiscountPolicy'ye bırakıyor. Bu kodu unit test ile test etmek, her collaborator için bir mock kurmak demek; az sayıda integration test hem daha ucuz hem daha güvenilir.

Sol alt köşe trivial kod: Book.Title { get; set; }, DTO (Data Transfer Object) record'ları, tek satırlık mapping'ler. Test etme. Sağ üst köşe aşırı karmaşık kod: hem karmaşık hem çok collaborator'lı. İndirimi hesaplayan, stoğu kontrol eden, ödemeyi çağıran ve e-postayı aynı 200 satırlık method'da gönderen bir OrderManager.Process() düşün. Bunu test etmenin her yolu pahalı. Önce hesabı domain'e, koordinasyonu ince bir orchestration katmanına ayırırsın; sonra her parça kendi köşesinde test edilir. Bu ayrımın tasarım tarafını ilerideki Unit Test serisinin "Test Edilebilir Tasarım: Dependency Injection, Seam'ler, Functional Core ve Humble Object" yazısı anlatıyor.

Managed ve Unmanaged Bağımlılıklar

Orchestration kodunu integration test ile test etmeye karar verdin. Şimdi ikinci soru: bağımlılıklardan hangisi testte gerçek olacak, hangisi mock? Khorikov process dışındaki bağımlılıkları ikiye ayırıyor. Managed bağımlılık, yalnız senin uygulamanın eriştiği ve tamamen senin kontrolünde olan bir sistem: Bookstore'un kendi veritabanı. Ona yapılan çağrılar dışarıdan görünmez; dışarıdan görünen, veritabanının son halidir. Unmanaged bağımlılık ise başka sistemlerin de gördüğü bir sistem: ödeme sağlayıcısı, e-posta sunucusu, başka service'lerin dinlediği bir message bus. Ona giden her çağrı, sistemin dışarıdan gözlemlenebilir davranışının bir parçası.

Kural buradan çıkıyor: managed bağımlılıklar testte gerçek olur, unmanaged bağımlılıklar mock'lanır. Bookstore'un integration testi bunu satır satır uyguluyor:

[Fact]
public async Task Placing_an_order_lowers_stock_charges_and_sends_email()
{
    OrderService service = new(new EfStockRepository(_db), _payments, _email);

    OrderResult result = await service.PlaceAsync("ada@example.com", 1, 2, TestContext.Current.CancellationToken);

    Assert.Equal(OrderResult.Placed, result);
    // managed bağımlılık: sonucu veritabanının son halinden oku
    Assert.Equal(3, await _db.Books.Where(b => b.Id == 1).Select(b => b.Stock).SingleAsync(TestContext.Current.CancellationToken));
    // unmanaged bağımlılıklar: dışarıdan görünen çağrıları doğrula
    await _payments.Received(1).ChargeAsync("ada@example.com", 640m, Arg.Any<CancellationToken>());
    await _email.Received(1).SendAsync("ada@example.com", "Your order for Clean Architecture is confirmed", Arg.Any<CancellationToken>());
}

_db bellekte açık tutulan bir SQLite bağlantısına bağlı gerçek bir DbContext; _payments ve _email NSubstitute mock'ları. Mock'lanan iki çağrı, ödeme sağlayıcısının ve müşterinin gerçekten göreceği şeyler. 640 TL'lik çekim ve "Your order for Clean Architecture is confirmed" konulu e-posta bir sözleşme; bunları doğrulamak implementation'a bağlanmak değil. Test double türlerinin ve mocking kütüphanelerinin ayrıntısı ilerideki Unit Test serisinde.

Implementation Detail mi, Observable Behavior mı?

Şimdi diğer teste bakalım. ASP.NET Core Web API (Application Programming Interface) serisinin 29. yazısında service'i test ederken repository'yi Moq ile mock'lamıştık; bir class'ı yalıtarak test etmeyi öğrenmek için iyi bir başlangıç. Aynı refleksle yazılmış test şu:

[Fact]
public async Task Placing_an_order_saves_the_lowered_stock()
{
    IStockRepository stock = Substitute.For<IStockRepository>();
    IPaymentGateway payments = Substitute.For<IPaymentGateway>();
    IEmailSender email = Substitute.For<IEmailSender>();
    Book book = new() { Id = 1, Title = "Clean Architecture", Price = 320m, Stock = 5 };
    stock.GetAsync(1, Arg.Any<CancellationToken>()).Returns(book);
    payments.ChargeAsync("ada@example.com", 640m, Arg.Any<CancellationToken>()).Returns(new PaymentResult(true, "tx-1"));

    OrderResult result = await new OrderService(stock, payments, email)
        .PlaceAsync("ada@example.com", 1, 2, TestContext.Current.CancellationToken);

    Assert.Equal(OrderResult.Placed, result);
    await stock.Received(1).GetAsync(1, Arg.Any<CancellationToken>());
    await stock.Received(1).SaveAsync(Arg.Is<Book>(b => b.Stock == 3), Arg.Any<CancellationToken>());
}

İki test de yeşil. Sonra eşzamanlı iki siparişin aynı son kitabı satabildiğini fark ediyoruz: stoğu okuyup bellekte düşürüp kaydetmek, araya giren ikinci isteğe açık. Stok düşüşünü tek bir koşullu UPDATE'e çeviriyoruz:

public async Task<bool> TryDecreaseAsync(int bookId, int quantity, CancellationToken ct) =>
    await db.Books
        .Where(b => b.Id == bookId && b.Stock >= quantity)
        .ExecuteUpdateAsync(s => s.SetProperty(b => b.Stock, b => b.Stock - quantity), ct) == 1;

OrderService'te de book.Stock -= quantity; await stock.SaveAsync(book, ct); satırlarının yerine TryDecreaseAsync çağrısı geliyor. Müşteri aynı sonucu alıyor, ödeme sağlayıcısı aynı çağrıyı görüyor, veritabanındaki stok yine 3. Testler:

    Bookstore.Ordering.Tests.OrderServiceMockTests.Placing_an_order_saves_the_lowered_stock [FAIL]
    Bookstore.Ordering.Tests.OrderServiceMockTests.Placing_an_order_saves_the_lowered_stock [FINISHED] Time: 0.034s
    Bookstore.Ordering.Tests.OrderServiceTests.Placing_an_order_lowers_stock_charges_and_sends_email [FINISHED] Time: 0.331s
   Bookstore.Ordering.Tests  Total: 2, Errors: 0, Failed: 1, Skipped: 0, Not Run: 0, Time: 0.372s

Integration test geçti, mock'lu test kırıldı:

  Assert.Equal() Failure: Values differ
  Expected: Placed
  Actual:   OutOfStock

Mock'un yeni method için bilmediği bir cevabı yok; NSubstitute TryDecreaseAsync için varsayılan false'u döndü ve service stok bitti sandı. Testi "düzeltmek" için o method'u da stub'ladığımızda bu kez başka bir yerden kırıldı:

  Xunit.MicrosoftTestingPlatform.XunitException: NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching:
  	SaveAsync(b => (b.Stock == 3), any CancellationToken)
  Actually received no matching calls.

Önce Tahmin Et'in cevabı bu: kırılan test hiçbir hata yakalamadı. Doğruladığı şey davranış değil, davranışın nasıl gerçekleştiğiydi: önce GetAsync, sonra SaveAsync. Khorikov bunu refactoring'e dayanıklılığın kaybı olarak tanımlıyor ve iyi bir testin dört özelliğinden biri sayıyor. Bu tür bir test zamanla ekibe "refactoring yapınca testler kırılıyor, o zaman refactoring yapmayalım" dedirtir; testin varlık sebebinin tam tersi. Stok repository'si managed bir bağımlılığın önüne konmuş bir abstraction; ona giden çağrılar kimseye görünmüyor, o yüzden doğrulanmaları da gerekmiyor.

Risk Tabanlı Önceliklendirme

Dörtgen kodun türünü söylüyor, ama zaman sınırlı. Hangi davranışın önce ve daha derin test edileceğine risk karar verir. Bookstore için dört soru soruyoruz.

Para mı? İndirim hesabı, ödeme tutarı, iade. Bir kuruşluk yuvarlama hatası binlerce siparişte çarpılır. Güvenlik mi? Başka bir müşterinin siparişini görebilmek, authorization kontrolünün atlanması, ödeme callback'inin imzasının doğrulanmaması. Veri kaybı mı? Stoğun eksiye düşmesi, bir siparişin yarıda kaydedilmesi, bir migration'ın kolonu silmesi. Sık değişiyor mu? Bir dosyanın son altı ayda kaç commit aldığını görmek için:

git log --since=6.months --name-only --format= | sort | uniq -c | sort -rn | head

Hem riskli hem sık değişen kod, testin en yüksek getiriyi verdiği yer. Hiç test etmemen gereken şeyler de var: framework'ün kendisi (ASP.NET Core'un routing'ini, Entity Framework Core'un (EF Core) SaveChanges'ini Microsoft test ediyor), auto property'ler, üçüncü parti bir kütüphanenin kendi davranışı. Bunları dolaylı olarak, kendi davranışını test ederken zaten kullanıyorsun.

Bookstore İçin Seviye Kararları

Dörtgeni, managed/unmanaged ayrımını ve riski birleştirince dört özellik için karar tablosu şöyle çıkıyor:

DavranışKod türüSeviyeGerçekMockGerekçe
Sipariş indirimiDomainUnit (çok sayıda, sınır değerleri)Her şeyHiçbir şeyPara, sık değişen kampanya kuralları
Stok düşmeOrchestrationIntegration (gerçek veritabanı)Veritabanı, repositoryÖdeme, e-postaVeri kaybı, eşzamanlılık
Ödeme callback'iOrchestration + güvenlikIntegration + contractEndpoint, imza doğrulama, veritabanıÖdeme sağlayıcısının kendisiPara, güvenlik, aynı callback'in iki kez gelmesi
Angular sepet component'iUI (User Interface) + küçük hesapComponent test, bir E2E (End-to-End) akışComponent, store, templateHTTP (Hypertext Transfer Protocol)Kullanıcının gördüğü toplam, stok sınırı

Ödeme callback'i ayrıca ilginç: bu kez unmanaged bağımlılık bizi çağırıyor. Sağlayıcıyı test ortamına bağlamak yerine, sağlayıcının dokümante ettiği payload'ı ve imzayı testte üretip kendi endpoint'imize gönderiyoruz; imzası bozuk istek 401 almalı, aynı callback ikinci kez geldiğinde sipariş ikinci kez ödenmiş sayılmamalı. Payload'ın sağlayıcının gerçekten gönderdiği şeyle uyumlu kaldığını contract test garanti eder.

Tek Sayfalık Test Stratejisi Dokümanı

Bu kararlar yazıya dökülmezse her pull request'te yeniden tartışılır. Repo'nun kökünde duran, bir sayfayı geçmeyen bir doküman yeterli:

# Bookstore test strategy (v1, 2026-11)

## Levels
| Level       | What                                     | Size   | Tool                     | Runs           | Owner        |
|-------------|------------------------------------------|--------|--------------------------|----------------|--------------|
| Static      | all code                                 | -      | Nullable, analyzers, tsc | every build    | everyone     |
| Unit        | domain rules (pricing, stock rules)      | small  | xUnit.net v3             | every commit   | feature team |
| Component   | Angular components                       | small  | Vitest + TestBed         | every commit   | web team     |
| Integration | API + real database, fake payment/email  | medium | WebApplicationFactory    | every PR       | feature team |
| Contract    | web <-> API, payment callback payloads   | medium | Pact                     | every PR       | API + web    |
| E2E         | 5 critical journeys                      | medium | Playwright               | before deploy  | QA + teams   |
| Smoke       | /health, /api/books                      | large  | smoke.sh                 | after deploy   | platform     |

## Rules
- Mock only unmanaged dependencies (payment, email, message bus). The database is real.
- Every production bug gets a regression test at the lowest level that reproduces it.
- No tests for trivial code (DTOs, auto properties, framework behavior).
- A flaky test is quarantined within 24 hours; its owner fixes or deletes it within a week.

## Covered in production, not by tests
- Payment provider outages: alert on payment error rate above 2% for 5 minutes.
- New checkout flow ships behind the "checkout-v2" feature flag.

Dokümanın değeri tablodan çok kurallar kısmında. "Veritabanı gerçek" kuralı, bir önceki yazıdaki InMemory provider tuzağını ve yukarıdaki kırılgan mock testini code review'da tek cümleyle durdurur. Sahip sütunu da önemli: sahibi olmayan bir flaky test kimsenin sorunu olmaz.

Üretimde Dikkat

Test, üretimden önce yapılan bir tahmindir; ne kadar iyi olursa olsun gerçek trafiği, gerçek veriyi ve üçüncü parti service'lerin gerçek davranışını göremez. Shift-right yaklaşımı, güvenin bir kısmını bilerek üretime taşır. Production monitoring, ödeme hata oranı gibi iş metriklerini izleyip eşik aşılınca alarm verir; strateji dokümanındaki "yüzde 2, 5 dakika" satırı budur. Synthetic monitoring, smoke testini deploy anında değil her birkaç dakikada bir koşar. Feature flag, yeni ödeme akışını önce yüzde 5'lik bir kullanıcı grubuna açıp metrikler kötüleşirse koda dokunmadan kapatmayı sağlar; mekaniği Deployment Stratejileri serisinin "Feature Flag'ler: Deploy'u Release'ten Ayırmak, Microsoft.FeatureManagement ve OpenFeature" yazısında.

Shift-right, testi azaltmanın bahanesi değil. Monitoring hatayı müşteri yaşadıktan sonra görür; test ise müşteri yaşamadan. İkisi aynı soruya farklı zamanlarda cevap verir: strateji dokümanında hangi riskin hangisine bırakıldığı açıkça yazmalı.

Kararları kodla birlikte gözden geçir. Dörtgendeki yerini değiştiren kod, testinin seviyesini de değiştirir: OrderService bir gün indirim hesabını kendi içine alırsa, o hesap için unit test yazılabilir hale gelene kadar refactor etmek gerekir.

Sık Düşülen Tuzaklar

Her class için bir test class'ı, her collaborator için bir mock. Bu refleks orchestration kodunu implementation detail'e bağlar; yukarıda gördüğümüz gibi davranışı değişmeyen bir refactoring iki ayrı yerden kırmızı yakar.

Kendi veritabanını mock'lamak. Repository'yi mock'lamak, ince bir API'de kodun kırılabileceği tek yeri test dışında bırakır. Veritabanı managed bir bağımlılık; gerçek olsun.

Unmanaged bağımlılığı gerçek haliyle testte çağırmak. Ödeme sağlayıcısının sandbox'ına her pull request'te istek atan bir test, sağlayıcı yavaşladığında senin pipeline'ını yavaşlatır; başka bir ekibin test verisini de kirletebilir.

Trivial kodu test etmek. Book'un Title property'sine değer atayıp geri okuyan bir test coverage'ı artırır, güveni artırmaz. Coverage'ın gerçekte ne ölçtüğü bir sonraki yazının konusu.

Aşırı karmaşık kodu olduğu gibi test etmeye çalışmak. 200 satırlık bir method'u kırk mock'la sarmalamak yerine önce dörtgende doğru köşelere ayır.

Küçük Bir Deneme

★☆☆ Kendi projendeki beş class'ı dörtgene yerleştir. Kaç tanesi sağ üst köşeye düştü?

★★☆ Projendeki dış bağımlılıkları listele ve her birini managed ya da unmanaged diye işaretle. Hangi testlerin managed bir bağımlılığı mock'ladığını bul ve birini gerçek veritabanıyla yeniden yaz.

★★★ Ekibin için tek sayfalık bir test stratejisi dokümanı yaz: seviyeler tablosu, en az dört kural ve "testle değil üretimde karşılanan riskler" bölümü. Bir pull request'te ekiple birlikte gözden geçir.

Neyi hangi seviyede test edeceğimizi biliyoruz. Ama yazdığımız testlerin gerçekten bir şeyi doğrulayıp doğrulamadığını nereden bileceğiz? Bir sonraki yazıda line ve branch coverage'ın ne söyleyip ne söylemediğine ve mutation testing'in bu açığı nasıl kapattığına bakacağız.