Mock'u Ne Zaman Kullanmamalı: Over-mocking, Kırılgan Testler ve "Sahibi Olmadığını Mock'lama"
Mock yalnız sistem sınırında, dışarıdan gözlenebilen unmanaged bağımlılıklar için kullanılır; domain object'leri ve uygulamanın kendi veritabanı mock'lanmaz. Mock'lanmış bir döviz kuru adapter'ı testleri yeşil tutarken production'da 100 EUR'yu 0 TRY gösterdi; HttpMessageHandler stub'ı hatayı ilk koşuda yakaladı. Output, state ve communication test stilleri ve managed/unmanaged ayrımı.
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 Türkiye'de fiyatları TRY (Türk lirası) olarak da göstermeye başlıyor. Döviz kuru dış bir sağlayıcıdan geliyor. Ekip sağlayıcıyı bir interface'in arkasına almış, adapter'ı yazmış ve fiyat dönüştürücüsünü test etmiş:
public interface IExchangeRates
{
Task<decimal> GetRateAsync(string from, string to, CancellationToken ct);
}
// Adapter: sahibi olmadığımız döviz kuru API'si ile tek temas noktası
public sealed class ExchangeRateClient(HttpClient http) : IExchangeRates
{
public async Task<decimal> GetRateAsync(string from, string to, CancellationToken ct)
{
RateResponse? body = await http.GetFromJsonAsync<RateResponse>($"latest?base={from}&symbols={to}", ct);
return body?.Rate ?? throw new InvalidOperationException("Empty rate response.");
}
private sealed record RateResponse(decimal Rate);
}
public sealed class PriceConverter(IExchangeRates rates)
{
public async Task<decimal> ToLocalAsync(decimal priceInEur, string currency, CancellationToken ct)
{
decimal rate = await rates.GetRateAsync("EUR", currency, ct);
return Math.Round(priceInEur * rate, 2, MidpointRounding.AwayFromZero);
}
}
[Fact]
public async Task Price_is_converted_with_the_current_rate()
{
IExchangeRates rates = Substitute.For<IExchangeRates>();
rates.GetRateAsync("EUR", "TRY", Arg.Any<CancellationToken>()).Returns(48.73m);
PriceConverter sut = new(rates);
decimal price = await sut.ToLocalAsync(100m, "TRY", TestContext.Current.CancellationToken);
Assert.Equal(4873.00m, price);
}
Test yeşil. Sağlayıcının latest?base=EUR&symbols=TRY çağrısına verdiği yanıt şu biçimde:
{ "base": "EUR", "date": "2026-11-06", "rates": { "TRY": 48.73 } }
Production'da 100 EUR'luk bir kitap sitede kaç TRY görünür?
Cevap: 0 TRY
Adapter'ı mock'lamadan, yalnız altındaki HTTP (HyperText Transfer Protocol) çağrısını taklit eden bir testle çalıştırdığımda:
failed Bookstore.Pricing.Tests.ExchangeRateClientTests.Reads_the_rate_from_the_provider_response (1ms)
Assert.Equal() Failure: Values differ
Expected: 48.73
Actual: 0
RateResponse, yanıtta en üst seviyede bir rate alanı bekliyor; gerçek yanıtta kur rates sözlüğünün içinde. System.Text.Json eksik property'yi hata saymıyor, Rate'i varsayılan değeriyle, 0 ile dolduruyor; ?? throw da body null olmadığı için hiç çalışmıyor. Sonuç: 100 EUR × 0 = 0 TRY. PriceConverterTests'teki mock, adapter'ın ne döndüreceğine dair ekibin varsayımını taklit etti ve o varsayım yanlıştı. Testler yeşil, production kırmızı. Bu yazı mock'un nerede değer kattığını, nerede böyle bir yanılgı ürettiğini ayırıyor.
Üç Test Stili: Output, State ve Communication
Khorikov unit testleri doğrulama biçimine göre üç stile ayırır:
public sealed class TestStyleTests
{
private const string Ddd = "978-0321125217";
// Output-based: girdi ver, dönüşe bak
[Fact]
public void Orders_below_50_pay_standard_shipping() =>
Assert.Equal(4.99m, ShippingFee.For(49.99m));
// State-based: işlemi çalıştır, object'in durumuna bak
[Fact]
public void Adding_the_same_book_twice_sums_quantities()
{
Cart cart = new();
cart.Add(Ddd, 1);
cart.Add(Ddd, 2);
Assert.Equal(3, cart.Items[Ddd]);
}
// Communication-based: sistem sınırına giden çağrıyı doğrula
[Fact]
public async Task Taking_stock_below_three_alerts_purchasing()
{
IStockAlerts alerts = Substitute.For<IStockAlerts>();
StockKeeper keeper = new(alerts);
keeper.Receive(Ddd, 5);
await keeper.TakeAsync(Ddd, 3, TestContext.Current.CancellationToken);
await alerts.Received(1).LowStockAsync(Ddd, 2, Arg.Any<CancellationToken>());
}
}
Output-based test yan etkisi olmayan bir fonksiyona girdi verir ve dönüşe bakar. Refactoring'e en dayanıklı stil budur: fonksiyonun içi nasıl değişirse değişsin, aynı girdi aynı çıktıyı veriyorsa test yeşil kalır. State-based test bir işlem çalıştırır ve object'in (ya da collaborator'ının) sonraki durumuna bakar. Durum public API'den (Application Programming Interface) okunuyorsa o da dayanıklıdır. Communication-based test, test edilen kodun collaborator'larına yaptığı çağrıları mock ile doğrular. Üçünün en kırılganı: çağrının adı, argümanları ya da sayısı değiştiğinde davranış aynı olsa bile test kırılır.
Bu bir sıralama: mümkünse output, değilse state, ancak gerekiyorsa communication. Ama communication-based test yanlış bir stil değil. IStockAlerts satın alma ekibine giden bir bildirim; "stok 3'ün altına düştüğünde satın almaya haber verilir" bir iş kuralı ve dışarıdan gözlenebilen tek sonucu o çağrı. Soru, hangi çağrının gözlenebilir davranış, hangisinin implementation detayı olduğu.
Mock Yalnız Sistem Sınırında: Managed ve Unmanaged Bağımlılıklar
Khorikov'un ayrımı bu soruyu cevaplıyor. Bir bağımlılık, uygulamanın dışından gözlenebiliyorsa ve başka sistemlerle paylaşılıyorsa unmanaged'dır: ödeme sağlayıcısı, e-posta, SMS (Short Message Service), message bus, başka ekiplerin API'leri. Bunlarla yapılan konuşma, uygulamanın dış dünyaya karşı sözleşmesidir ve mock ile doğrulanmalıdır. Bir bağımlılığa yalnız senin uygulaman erişiyorsa managed'dır; tipik örnek uygulamanın kendi veritabanı. Ona yapılan çağrılar implementation detayıdır: tabloya hangi sırayla yazdığın değil, sonunda veritabanında ne olduğu önemlidir. Managed bağımlılıklar mock'lanmaz; gerçekleriyle integration test'te test edilir.
| Bağımlılık | Tür | Unit test'te |
|---|---|---|
Order, Cart, PriceCalculator | Domain kodu, process içi | Gerçeği kullanılır, asla mock'lanmaz |
Uygulamanın kendi veritabanı (DbContext) | Managed, process dışı | Mock'lanmaz; integration test'te gerçeğiyle |
| Ödeme, e-posta, satın alma bildirimi | Unmanaged | Mock ya da spy ile doğrulanır |
| Döviz kuru sağlayıcısı (yalnız okuma) | Unmanaged, sorgu | Stub ile veri verilir, çağrısı doğrulanmaz |
TimeProvider, rastgelelik | Process içi ama deterministik değil | Fake ile kontrol edilir |
Tablo, Test Stratejisi serisindeki "neyi hangi seviyede test etmeli" sorusunun unit test tarafı. Domain object'leri asla mock'lanmaz: Order'ın bir mock'u, testi Order'ın gerçek davranışından koparır ve Order'daki bir hata hiçbir testte görünmez.
HttpClient: HttpMessageHandler Stub'ı ve Kendi Adapter'ın
"Don't mock what you don't own" (sahibi olmadığını mock'lama) kuralı Steve Freeman ve Nat Pryce'ın Growing Object-Oriented Software, Guided by Tests (GOOS, 2009) kitabından geliyor. Gerekçe Önce Tahmin Et'te gördüğümüz şey: başkasının API'sini mock'ladığında, o API'nin nasıl davrandığına dair kendi varsayımını test edersin. Varsayım yanlışsa test bunu asla söylemez.
Kuralın pratik karşılığı iki katman. Birincisi, sahibi olmadığın şeyin önüne kendi interface'ini koy: IExchangeRates, Bookstore'un diliyle konuşur ("EUR/TRY kuru nedir?") ve PriceConverter gibi kodları test ederken stub'lanır. İkincisi, adapter'ın kendisini yabancı API'nin gerçek biçimine karşı test et. HttpClient'ın method'ları virtual değil, ama bütün istekler bir HttpMessageHandler'dan geçer ve o taklit edilebilir:
// HttpClient'ın kendisi değil, altındaki handler taklit edilir
public sealed class StubHttpMessageHandler(HttpStatusCode status, string json) : HttpMessageHandler
{
public List<Uri?> RequestedUris { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
RequestedUris.Add(request.RequestUri);
return Task.FromResult(new HttpResponseMessage(status)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
});
}
}
public sealed class ExchangeRateClientTests
{
// Sağlayıcının yanıt biçimi: dokümandan ya da kayıtlı gerçek bir yanıttan kopyalanır
private const string LatestEurTry = """
{ "base": "EUR", "date": "2026-11-06", "rates": { "TRY": 48.73 } }
""";
private static ExchangeRateClient CreateClient(StubHttpMessageHandler handler) =>
new(new HttpClient(handler) { BaseAddress = new Uri("https://rates.example.com/") });
[Fact]
public async Task Reads_the_rate_from_the_provider_response()
{
ExchangeRateClient client = CreateClient(new StubHttpMessageHandler(HttpStatusCode.OK, LatestEurTry));
decimal rate = await client.GetRateAsync("EUR", "TRY", TestContext.Current.CancellationToken);
Assert.Equal(48.73m, rate);
}
[Fact]
public async Task Requests_latest_rate_for_the_currency_pair()
{
StubHttpMessageHandler handler = new(HttpStatusCode.OK, LatestEurTry);
await CreateClient(handler).GetRateAsync("EUR", "TRY", TestContext.Current.CancellationToken);
Uri? uri = Assert.Single(handler.RequestedUris);
Assert.Equal("https://rates.example.com/latest?base=EUR&symbols=TRY", uri?.ToString());
}
[Fact]
public async Task Provider_outage_surfaces_as_http_request_exception()
{
ExchangeRateClient client = CreateClient(new StubHttpMessageHandler(HttpStatusCode.ServiceUnavailable, "{}"));
HttpRequestException ex = await Assert.ThrowsAsync<HttpRequestException>(
() => client.GetRateAsync("EUR", "TRY", TestContext.Current.CancellationToken));
Assert.Equal(HttpStatusCode.ServiceUnavailable, ex.StatusCode);
}
}
İlk test hatayı yakaladı. Adapter'ın düzeltilmiş hâli yanıtın gerçek biçimini okuyor ve kur yoksa sessizce 0 dönmek yerine exception fırlatıyor:
public async Task<decimal> GetRateAsync(string from, string to, CancellationToken ct)
{
LatestRates? body = await http.GetFromJsonAsync<LatestRates>($"latest?base={from}&symbols={to}", ct);
if (body?.Rates is null || !body.Rates.TryGetValue(to, out decimal rate))
{
throw new InvalidOperationException($"No {from}/{to} rate in provider response.");
}
return rate;
}
private sealed record LatestRates(string Base, Dictionary<string, decimal>? Rates);
Dört test yeşil. Bu testler process içinde, milisaniyelerde çalışıyor; ağ yok. Ama tek başına yeterli değiller: sağlayıcı yarın yanıt biçimini değiştirirse kayıtlı JSON (JavaScript Object Notation) eskir. Adapter'ı gerçek sağlayıcıya (ya da onun sandbox'ına) karşı birkaç integration testle korumak ve consumer-driven contract testing, Integration ve E2E (end-to-end) Test serisinin konusu. Unit test, adapter'ın bildiğimiz biçimi doğru okuduğunu garanti eder; biçimin hâlâ o olduğunu garanti etmez.
Bu test için mock'lanabilir bir IHttpClientWrapper yazmak da sık görülen bir yol, ama aynı sorunu bir kat aşağı taşır: wrapper'ın mock'u yine sağlayıcının yanıtına dair bir varsayımdır. Handler stub'ı gerçek HttpClient'ı, gerçek JSON serileştirmeyi ve gerçek status code işlemeyi çalıştırır; taklit edilen tek şey ağ.
DbContext'i ve Domain Object'lerini Mock'lama
DbContext ve DbSet<T>'yi mock'lamak teknik olarak mümkün, ama sonuç LINQ (Language Integrated Query) sorgularının bellekte çalıştığı, gerçek SQL (Structured Query Language) çevirisinin, ilişkilerin, unique index'lerin ve transaction'ların hiç denenmediği bir testtir. Entity Framework Core (EF Core) serisinin 22. yazısı InMemory provider'ın aynı tuzaklarını ve SQLite in-memory ile gerçek veritabanı alternatifini anlatıyor. Uygulamanın kendi veritabanı managed bir bağımlılık; onu kullanan kodu ya repository arkasında, ya da gerçek veritabanıyla integration test'te test et.
Domain object'leri için kural daha basit: hiç mock'lama. Order'ın bir interface'i olsa bile, Substitute.For<IOrder>() ile Total'ın 96 döndüğünü ayarlamak, Order'ın toplamı nasıl hesapladığını testten çıkarır. Gerçek bir Order oluşturmak zahmetliyse çözüm mock değil, 10. yazıdaki test data builder'lar.
Üretimde Dikkat
Yanıt örneklerini kaynakla birlikte sakla. Adapter testlerindeki JSON'u uydurmak yerine sağlayıcının dokümanından ya da kayıtlı gerçek bir yanıttan al ve dosyanın yanına nereden geldiğini yaz. Sağlayıcı sürüm değiştirdiğinde güncellenecek yer belli olur.
Adapter'ı ince tut. İş kuralları (yuvarlama, para birimi seçimi, cache süresi) adapter'da değil, onu kullanan kodda dursun. İnce bir adapter'ın birkaç handler testi ve birkaç integration testi yeterli; kalın bir adapter, mock'lanamayan iş kurallarını sistem sınırına taşır.
IHttpClientFactory ile kullanılan adapter'larda handler stub'ı aynı şekilde çalışır; production'daki resilience handler'ları (retry, circuit breaker) ise ayrı bir konu. Onların davranışını unit test'te değil, gerçek ya da emüle edilmiş bir uç noktayla test etmek daha anlamlı.
Sık Düşülen Tuzaklar
"Her şey mock'lu, testler yeşil, production kırmızı". Önce Tahmin Et'in kendisi. Bütün collaborator'ları mock'lanmış bir servis testi, collaborator'ların gerçek davranışlarına dair hiçbir şey söylemez. Unit test suite'inin her sınır için en az bir yerde gerçeğe değmesi gerekir: adapter testi, integration test ya da contract test.
Sorguları doğrulamak. rates.Received(1).GetRateAsync(...) yazmak, önceki yazıdaki stub'a assert etme hatası. Kurun nasıl alındığı değil, fiyatın doğru hesaplandığı önemli. Yarın kura bir cache eklendiğinde bu satır yanlış alarm verir.
Domain object'lerini mock'lamak. Yukarıda anlatıldı; en pahalı hatalardan biri, çünkü testler domain'in kendisini hiç çalıştırmaz.
Mock'tan kaçarken gerçek dış servise gitmek. Bu yazının mesajı "mock'u azalt" ama çözüm unit test'te gerçek döviz sağlayıcısını çağırmak değil. Ağa giden test yavaş, deterministik olmayan ve sağlayıcının kotasını tüketen bir integration testtir; ayrı bir projede ve ayrı bir pipeline adımında durmalı.
Küçük Bir Deneme
★☆☆ ExchangeRateClientTests'e iki test ekle: yanıt "rates": {} olduğunda ve yanıt gövdesi boş bir JSON object olduğunda adapter ne yapıyor? İkisi de anlamlı bir exception veriyor mu?
★★☆ PriceConverterTests'i NSubstitute yerine gerçek ExchangeRateClient ve StubHttpMessageHandler ile yeniden yaz. Test daha mı uzun oldu? Önce Tahmin Et'teki hatayı bu hâliyle yakalar mıydı?
★★★ Kendi projende en çok mock kullanan test class'ını seç. Her mock'u tablodaki türlerden birine yerleştir: domain, managed, unmanaged komut, unmanaged sorgu. Domain ve managed olanları gerçeğiyle ya da fake ile değiştir, sorguları stub'a çevir. Kaç Received/Verify satırı kaldı?
Bir sonraki yazıda mock ihtiyacını azaltmanın asıl yoluna geçiyoruz: test edilebilir tasarım. Constructor injection, seam'ler, TimeProvider, functional core / imperative shell ve Humble Object.