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

Test Double'lar: Dummy, Stub, Spy, Mock ve Fake Arasındaki Fark

Mert Özen 8 Kas 2026 10 dk 2 görüntülenme
Test Double'lar: Dummy, Stub, Spy, Mock ve Fake Arasındaki Fark

Bir test double'ın türünü kütüphanesi değil testteki rolü belirler: dummy yer doldurur, stub veri verir, spy ve mock dışarı giden çağrıyı doğrular, fake çalışan bir kestirmedir. Meszaros taksonomisi, Fowler'ın state ve behavior verification ayrımı, stub'a assert etmenin zararı, kaydı kopyalamayan bir fake'in eksik SaveAsync'i nasıl gizlediği ve FakeTimeProvider ile FakeLogger.

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

Önce Tahmin Et

Bookstore'da sipariş onaylandığında OrderConfirmationService siparişi onaylı olarak kaydediyor, müşteri dizininden müşteriyi buluyor ve onay e-postası gönderiyor. Müşterinin e-posta adresi yoksa bir warning log'u yazıyor:

public sealed partial class OrderConfirmationService(
    IOrderRepository orders,
    ICustomerDirectory customers,
    IEmailSender email,
    TimeProvider time,
    ILogger<OrderConfirmationService> logger)
{
    public async Task<bool> ConfirmAsync(Guid orderId, CancellationToken ct)
    {
        Order order = await orders.FindAsync(orderId, ct)
            ?? throw new KeyNotFoundException($"Order {orderId} was not found.");

        if (order.Status != OrderStatus.Placed)
        {
            return false;
        }

        order.Confirm(time.GetUtcNow());
        await orders.SaveAsync(order, ct);

        Customer? customer = await customers.FindAsync(order.CustomerId, ct);
        if (customer?.Email is null)
        {
            LogMissingEmail(order.Number, order.CustomerId);
            return true;
        }

        await email.SendAsync(
            new EmailMessage(customer.Email, $"Order {order.Number} confirmed", $"Hi {customer.Name}, thank you."),
            ct);
        return true;
    }

    [LoggerMessage(Level = LogLevel.Warning, Message = "Order {OrderNumber} confirmed but customer {CustomerId} has no email address")]
    private partial void LogMissingEmail(string orderNumber, string customerId);
}

Bu servis için yazılmış ilk test yeşil:

[Fact]
public async Task ConfirmAsync_sends_confirmation_email()
{
    Order placed = new(Guid.NewGuid(), "BS-1042", Ada.Id);
    IOrderRepository orders = Substitute.For<IOrderRepository>();
    orders.FindAsync(placed.Id, Arg.Any<CancellationToken>()).Returns(placed);
    ICustomerDirectory customers = Substitute.For<ICustomerDirectory>();
    customers.FindAsync(Ada.Id, Arg.Any<CancellationToken>()).Returns(Ada);
    IEmailSender email = Substitute.For<IEmailSender>();
    OrderConfirmationService sut = new(
        orders, customers, email, new FakeTimeProvider(Now),
        NullLogger<OrderConfirmationService>.Instance);

    await sut.ConfirmAsync(placed.Id, TestContext.Current.CancellationToken);

    await customers.Received(1).FindAsync(Ada.Id, Arg.Any<CancellationToken>());
    await email.Received(1).SendAsync(
        Arg.Is<EmailMessage>(m => m.To == "ada@example.com"), Arg.Any<CancellationToken>());
}

Testte beş test double var: orders, customers, email, FakeTimeProvider ve NullLogger. Hangisi dummy, hangisi stub, hangisi mock, hangisi fake? Ve iki Received satırından biri hem gereksiz hem zararlı; hangisi?

Cevap: İsim Değil, Testteki Rol

Beşi de aynı kütüphaneyle ya da aynı paketten gelse de testteki rolleri farklı. NullLogger bir dummy: constructor istediği için veriliyor, test ona hiç bakmıyor. orders ve customers stub: servise veri sağlıyorlar. email bir mock: testin geçip kalmasına onunla yapılan konuşma karar veriyor. FakeTimeProvider adındaki gibi bir fake: zamanı gerçekten tutan, ama elle ilerletilen çalışan bir implementation.

Gereksiz ve zararlı satır customers.Received(1). Müşteriyi bulmak servisin amacı değil, amaca giden yoldaki bir adım. Bu satır yarın birinin müşteriyi bir cache'ten okumaya karar vermesiyle kırmızıya döner, ama müşteri yine e-postasını alır. Stub'a assert etmek, 01. yazıdaki kırılgan London testinin aynı hatasıdır. Bu yazıda test double türlerini isimleriyle ayırıyor, elle yazılmış double'ların ne zaman daha iyi olduğunu ve .NET'in hazır fake'lerini görüyoruz.

Meszaros Taksonomisi: Beş Test Double

"Test double" terimi Gerard Meszaros'un xUnit Test Patterns (2007) kitabından geliyor; film setindeki dublörün (stunt double) testteki karşılığı. Meszaros beş tür tanımlar:

TürNe yapar?Test ona assert eder mi?Bookstore örneği
DummyYalnız parametre listesini doldurur, hiç kullanılmazHayırNullLogger<T>.Instance
StubÇağrılara hazır cevap verirHayırStubCustomerDirectory
SpyStub gibi davranır ve çağrıları kaydederEvet, kayda bakarakSpyEmailSender
MockBeklenen çağrılarla programlanır ve onları doğrularEvet, beklentiyleSubstitute.For<IEmailSender>() + Received
FakeÇalışan ama kestirme bir implementationDolaylı, state'e bakarakInMemoryOrderRepository, FakeTimeProvider

Khorikov bu beşi iki gruba indirger, çünkü pratikte ayrım iki yönde: stub'lar dışarıdan içeri gelen etkileşimleri taklit eder (servisin veri almak için yaptığı çağrılar), mock'lar içeriden dışarı giden etkileşimleri taklit eder ve inceler (servisin bir yan etki üretmek için yaptığı çağrılar). Dummy ve fake stub grubuna, spy mock grubuna düşer. Kural da buradan çıkar: içeri giden çağrıları (sorguları) doğrulama, dışarı giden çağrıları (komutları) doğrula. customers.FindAsync bir sorgu, email.SendAsync bir komut.

Fowler, State ve Behavior Verification

Martin Fowler'ın "Mocks Aren't Stubs" yazısı (ilk hâli 2004, son büyük güncellemesi 2007) aynı ayrımı doğrulama açısından anlatır. State verification: Act'ten sonra sistemin ve collaborator'ların durumuna bakarsın; InMemoryOrderRepository'deki siparişin Confirmed olması gibi. Behavior verification: test edilen kodun collaborator'larına doğru çağrıları yapıp yapmadığına bakarsın; Received(1).SendAsync(...) gibi. Fowler'ın yazısının asıl konusu, mock'un yalnız bir araç değil, 01. yazıda gördüğümüz mockist test üslubunun bir parçası olması.

Microsoft'un "Best practices for writing unit tests" dokümanı terimleri daha gevşek kullanıyor: "fake" stub ya da mock olabilen genel bir terim, "mock" testin geçip kalmasına karar veren fake, "stub" ise bir bağımlılığın kontrol edilebilir yerine geçeni. Dokümanın kendisi de bu kullanımın Fowler ve Meszaros'taki dar tanımlardan farklı olduğunu bir notla belirtiyor. .NET ekosisteminde bu yüzden aynı kelime iki anlamda dolaşıyor: Microsoft'un dilinde FakeOrder bir stub ya da mock olabilir, Meszaros'un dilinde fake çalışan bir alternatif implementation'dır. Bu seride Meszaros'un tanımlarını kullanıyoruz; önemli olan ekip içinde tek bir sözlük olması ve class adlarının role uyması.

Elle Yazılmış Test Double'lar

Önce Tahmin Et'teki testin aynı davranışı, kütüphanesiz double'larla:

// Fake: çalışan ama kestirme bir implementation
public sealed class InMemoryOrderRepository : IOrderRepository
{
    private readonly Dictionary<Guid, Order> store = [];

    public InMemoryOrderRepository(params Order[] seed)
    {
        foreach (Order order in seed)
        {
            store[order.Id] = Copy(order);
        }
    }

    public int SaveCount { get; private set; }

    public Task<Order?> FindAsync(Guid id, CancellationToken ct) =>
        Task.FromResult(store.TryGetValue(id, out Order? order) ? Copy(order) : null);

    public Task SaveAsync(Order order, CancellationToken ct)
    {
        store[order.Id] = Copy(order);
        SaveCount++;
        return Task.CompletedTask;
    }

    // Gerçek veritabanı gibi: kaydedilmeyen değişiklik görünmez
    private static Order Copy(Order o) => new(o.Id, o.Number, o.CustomerId, o.Status, o.ConfirmedAt);
}

// Stub: hazır cevap verir, test ona assert etmez
public sealed class StubCustomerDirectory(params Customer[] customers) : ICustomerDirectory
{
    public Task<Customer?> FindAsync(string customerId, CancellationToken ct) =>
        Task.FromResult(customers.FirstOrDefault(c => c.Id == customerId));
}

// Spy: çağrıları kaydeder, test sonra kayda bakar
public sealed class SpyEmailSender : IEmailSender
{
    public List<EmailMessage> Sent { get; } = [];

    public Task SendAsync(EmailMessage message, CancellationToken ct)
    {
        Sent.Add(message);
        return Task.CompletedTask;
    }
}
public sealed class OrderConfirmationTests
{
    private static readonly DateTimeOffset Now = new(2026, 11, 8, 9, 30, 0, TimeSpan.Zero);
    private static readonly Customer Ada = new("C-1", "Ada", "ada@example.com");

    private readonly Order placed = new(Guid.NewGuid(), "BS-1042", Ada.Id);
    private readonly SpyEmailSender email = new();
    private readonly FakeTimeProvider time = new(Now);

    [Fact]
    public async Task Confirmed_order_is_saved_with_confirmation_time()
    {
        InMemoryOrderRepository orders = new(placed);
        OrderConfirmationService sut = new(
            orders, new StubCustomerDirectory(Ada), email, time,
            NullLogger<OrderConfirmationService>.Instance);

        await sut.ConfirmAsync(placed.Id, TestContext.Current.CancellationToken);

        Order? saved = await orders.FindAsync(placed.Id, TestContext.Current.CancellationToken);
        Assert.Equal(OrderStatus.Confirmed, saved?.Status);
        Assert.Equal(Now, saved?.ConfirmedAt);
    }

    [Fact]
    public async Task Customer_receives_one_confirmation_email()
    {
        OrderConfirmationService sut = new(
            new InMemoryOrderRepository(placed), new StubCustomerDirectory(Ada), email, time,
            NullLogger<OrderConfirmationService>.Instance);

        await sut.ConfirmAsync(placed.Id, TestContext.Current.CancellationToken);

        EmailMessage message = Assert.Single(email.Sent);
        Assert.Equal("ada@example.com", message.To);
        Assert.Equal("Order BS-1042 confirmed", message.Subject);
    }
}

Elle yazılmış double'ın üç avantajı var. Okunurluk: Assert.Single(email.Sent) ve ardından gelen iki Assert.Equal, Arg.Is<EmailMessage>(m => ...) lambda'sından daha kolay okunuyor; mesaj yanlış olduğunda hata mesajı hangi alanın yanlış olduğunu da gösteriyor. Yeniden kullanım: SpyEmailSender e-posta gönderen her servisin testinde aynı. Refactoring güvenliği: interface'e yeni bir method eklendiğinde derleyici bütün double'ları gösterir.

Dezavantaj da belli: her interface için bir class. Interface'in on method'u varsa ve test yalnız birini kullanıyorsa elle yazmak israf; o durumda mocking kütüphanesi daha pratik, bir sonraki yazının konusu. Pratik bir kural: aynı double'ı üçüncü kez kütüphaneyle kurmaya başladığında, onu elle yazılmış bir class'a çevirmeyi düşün.

Fake'in Production'dan Sapması

InMemoryOrderRepository'deki Copy method'u süs değil. Aynı repository'nin saf bir hâlini yazdım, kaydettiği object'in kendisini tutup geri veren:

// Saf fake: kaydettiği object'in kendisini tutar ve geri verir
public sealed class NaiveInMemoryOrderRepository(params Order[] seed) : IOrderRepository
{
    private readonly Dictionary<Guid, Order> store = seed.ToDictionary(o => o.Id);

    public Task<Order?> FindAsync(Guid id, CancellationToken ct) =>
        Task.FromResult(store.GetValueOrDefault(id));

    public Task SaveAsync(Order order, CancellationToken ct)
    {
        store[order.Id] = order;
        return Task.CompletedTask;
    }
}

Sonra servisteki await orders.SaveAsync(order, ct); satırını yoruma aldım; yani production'da onay hiç kaydedilmiyor. İki fake ile aynı "sipariş onaylı kaydedilir" testinin sonucu:

failed Bookstore.Ordering.Tests.OrderConfirmationTests.Confirmed_order_is_saved_with_confirmation_time (0ms)
  Expected: Confirmed
  Actual:   Placed
  total: 4
  failed: 1
  succeeded: 3

Kopyalayan fake hatayı yakaladı. Saf fake ile yazılmış test (Confirmed_order_is_saved_naive_fake) ise yeşil kaldı: servis siparişin kendisini değiştirdi, fake aynı referansı geri verdi, kayıt hiç olmadığı hâlde test onu "kaydedilmiş" gördü. Gerçek bir veritabanında kaydedilmeyen değişiklik kaybolur; fake bu temel davranışı taklit etmiyorsa testler production'da olmayan bir dünyayı doğrular.

Fake yazmanın kuralı bu yüzden şu: fake, production'un testin güvendiği davranışlarını taklit etmeli. Repository için bu en az şunlar: kaydedilmeyen değişiklik görünmez, olmayan kayıt null döner, aynı anahtarla ikinci kayıt öncekinin üzerine yazar. Production'daki unique index gibi kısıtlar fake'te yoksa, o kısıtları test eden testler fake ile değil, Entity Framework Core (EF Core) serisinin 22. yazısında anlatılan gerçek veritabanı testleriyle yazılmalı.

Hazır Fake'ler: FakeTimeProvider ve FakeLogger

.NET 8 ile gelen TimeProvider abstract class'ı, zamanı dışarıdan almanın standart yolu oldu. Test tarafı Microsoft.Extensions.TimeProvider.Testing paketindeki FakeTimeProvider: parametresiz oluşturulduğunda 1 Ocak 2000 gece yarısı (UTC) gösterir, Advance ile ilerletilir, SetUtcNow ile ayarlanır. Yukarıdaki testlerde new FakeTimeProvider(Now) sayesinde ConfirmedAt'i tam bir değerle karşılaştırabildik; DateTime.UtcNow kullanan bir kodda bu assert imkânsızdı. Timer'lar, Task.Delay ve Advance ile zamanın atlanması 11. yazının konusu.

Log'u doğrulamak için Microsoft.Extensions.Diagnostics.Testing paketindeki FakeLogger<T> var. Yazılan her log kaydını Collector üzerinden okunabilir şekilde saklar:

[Fact]
public async Task Missing_email_is_logged_as_warning_and_nothing_is_sent()
{
    Customer noEmail = Ada with { Email = null };
    FakeLogger<OrderConfirmationService> logger = new();
    OrderConfirmationService sut = new(
        new InMemoryOrderRepository(placed), new StubCustomerDirectory(noEmail), email, time, logger);

    await sut.ConfirmAsync(placed.Id, TestContext.Current.CancellationToken);

    Assert.Empty(email.Sent);
    FakeLogRecord record = Assert.Single(logger.Collector.GetSnapshot());
    Assert.Equal(LogLevel.Warning, record.Level);
    Assert.Equal("Order BS-1042 confirmed but customer C-1 has no email address", record.Message);
}

FakeLogger'ı ILogger mock'una tercih etmenin nedeni teknik: LogInformation, LogWarning gibi method'lar extension method'dur ve mock'lanamaz; mock'lanan şey generic Log<TState> method'u, onu doğrulamak da okunmaz bir ifade gerektirir. FakeLogger ise kayıtları düz object olarak verir: seviye, biçimlenmiş mesaj, event id, structured state. Hangi log'ları doğrulamaya değer olduğu sorusu 11. yazıda.

Üretimde Dikkat

Paylaşılan fake'leri test projesinin ortak bir klasöründe tut ve onların da testi olsun. InMemoryOrderRepository on test class'ında kullanılıyorsa, ondaki bir hata on class'ın testlerini yanıltır. Fake'in kendisi için birkaç test ("kaydedilmeyen değişiklik görünmez") ucuz bir sigorta. Daha iddialı hâli, aynı test setini hem fake'e hem gerçek implementation'a karşı çalıştırmak (contract test); bunun gerçek veritabanı tarafı Integration ve E2E (end-to-end) Test serisinde.

Hazır fake paketlerinin sürümünü runtime ile hizala. Microsoft.Extensions.*.Testing paketleri .NET'in extension paketleriyle aynı aileden; 10.x paketleri 10.x runtime'ında, aynı major sürümle kullanıldığında sürpriz çıkarmaz.

Sık Düşülen Tuzaklar

Stub'a assert etmek. Önce Tahmin Et'teki customers.Received(1) satırı. Sorgu yapan bir collaborator'ın çağrıldığını doğrulamak, sonucun nasıl elde edildiğini kilitler. Stub'ın çağrılmadığı bir senaryo zaten yanlış bir sonuç üretir ve sonuca bakan assert onu yakalar.

Yanlış isim, yanlış niyet. MockCustomerDirectory adlı bir class'ın içinde yalnız hazır cevaplar varsa, testi okuyan kişi bir yerde onunla ilgili bir doğrulama arar. Microsoft dokümanı da aynı uyarıyı yapıyor: stub'larına "mock" dersen diğer geliştiriciler niyetin hakkında yanlış varsayımlarda bulunur. Class adını role göre koy: Stub..., Spy..., InMemory....

Fake'in production davranışından sapması. Yukarıdaki saf fake örneği. Bir başka yaygın hâli: production repository'si sayfalama yaparken fake bütün listeyi döner, ya da production'da büyük-küçük harf duyarsız olan arama fake'te duyarlıdır. Fake'i yazarken "production bu çağrıya ne yapar?" sorusunu her method için ayrı sor.

Her şeyi fake'lemek. Order, Customer, PriceCalculator gibi domain object'leri için double yazmak. Bunlar process içinde çalışan, deterministik kodlar; gerçeklerini kullan. Test double sistem sınırı içindir.

Küçük Bir Deneme

★☆☆ Önce Tahmin Et'teki testten customers.Received(1) satırını sil ve email.Received'i SpyEmailSender ile değiştir. Test kaç satır kısaldı, hata mesajı nasıl değişti?

★★☆ InMemoryOrderRepository'nin kendi testlerini yaz: kaydedilmeyen değişikliğin görünmediğini, olmayan id için null döndüğünü ve ikinci kaydın öncekinin üzerine yazdığını doğrula. Saf fake bu testlerden hangisinde kalıyor?

★★★ Kendi projende "Mock" ile başlayan class adlarını ara. Her biri için testteki gerçek rolünü (dummy, stub, spy, mock, fake) belirle ve adını role göre değiştir. Kaç tanesi aslında stub çıktı?

Bir sonraki yazıda kütüphanelere geçiyoruz: NSubstitute, Moq ve FakeItEasy ile argument matching, ardışık dönüşler, async method'lar, çağrı sayısı ve sırası, strict ve loose davranış ve Moq'un 2023'teki SponsorLink olayının bağımlılık seçimine dersi.