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

Mocking Kütüphaneleri Derinlemesine: NSubstitute, Moq ve FakeItEasy

Mert Özen 8 Kas 2026 11 dk 2 görüntülenme
Mocking Kütüphaneleri Derinlemesine: NSubstitute, Moq ve FakeItEasy

NSubstitute, Moq ve FakeItEasy aynı Castle DynamicProxy motorunu kullanır; bu yüzden yalnız interface ve virtual member'ları taklit edebilir ve aralarındaki fark büyük ölçüde syntax ile hata mesajlarıdır. Aynı ödeme senaryoları üç kütüphaneyle: argument matching ve yakalama, ardışık dönüş, async exception, çağrı sayısı ve sırası, strict mod, NS1000 analyzer uyarısı ve Moq'un 2023 SponsorLink olayı.

Sürüm notu: Bu yazıdaki kodlar .NET 10, xUnit.net v3 4.0.2, NSubstitute 6.2.0, NSubstitute.Analyzers.CSharp 1.0.17, Moq 4.21.0 ve FakeItEasy 9.0.1 ile derlenip denenmiştir.

Önce Tahmin Et

Kargo ücretini dış bir servisten alan ShippingRateClient, sahibi olmadığımız bir HTTP (HyperText Transfer Protocol) API'sini (Application Programming Interface) saran concrete bir class. Bir geliştirici onu doğrudan NSubstitute ile taklit etmeye karar veriyor:

// Sahibi olmadığımız bir HTTP client'ını saran concrete class
public class ShippingRateClient(HttpClient http)
{
    public async Task<decimal> GetRateAsync(string postcode, CancellationToken ct)
    {
        string body = await http.GetStringAsync($"rates/{postcode}", ct);
        return decimal.Parse(body, System.Globalization.CultureInfo.InvariantCulture);
    }
}
[Fact]
public async Task Shipping_rate_for_istanbul_is_4_99()
{
    ShippingRateClient client = Substitute.For<ShippingRateClient>(new HttpClient());
    client.GetRateAsync("34000", Arg.Any<CancellationToken>()).Returns(4.99m);

    decimal rate = await client.GetRateAsync("34000", TestContext.Current.CancellationToken);

    Assert.Equal(4.99m, rate);
}

Kod derlenir mi? Derlenirse test geçer mi, yoksa gerçek bir HTTP isteği mi atılır?

Cevap: Taklit Edilemeyen Method

Derleniyor, ama NSubstitute.Analyzers iki uyarı veriyor:

ConcreteMockTests.cs(12,9): warning NS1000: Member GetRateAsync can not be intercepted. Only interface members and virtual, overriding, and abstract members can be intercepted.
ConcreteMockTests.cs(12,38): warning NS1004: Argument matcher used with a non-virtual member of a class.

Test ise kalıyor:

NSubstitute.Exceptions.CouldNotSetReturnDueToNoLastCallException : Could not find a call to return from.

Make sure you called Returns() after calling your substitute (for example: mySub.SomeMethod().Returns(value)),
and that you are not configuring other substitutes within Returns() (for example, avoid this: mySub.SomeMethod().Returns(ConfigOtherSub())).

If you substituted for a class rather than an interface, check that the call to your substitute was on a virtual/abstract member.
Return values cannot be configured for non-virtual/non-abstract members.

NSubstitute, Moq ve FakeItEasy aynı tekniği kullanıyor: üçü de Castle.Core'un DynamicProxy'si ile çalışma anında bir alt class ya da interface implementation'ı üretiyor ve çağrıları override ettiği member'larda yakalıyor. GetRateAsync virtual olmadığı için override edilemiyor; setup satırındaki çağrı proxy'ye değil gerçek method'a gitti ve Returns yakalanmış bir çağrı bulamadı. Kötü senaryo şu: method'un bir yan etkisi olsaydı (gerçek bir istek, bir dosya yazma), setup sırasında çalışırdı.

Aynı sınır static method'lar, sealed class'lar, extension method'lar ve non-virtual member'lar için geçerli. Bunu kütüphanenin eksiği değil, bir tasarım sinyali olarak oku: ShippingRateClient'ı taklit etmek istiyorsan ihtiyacın olan şey bir seam. 09. yazıda seam'leri, 08. yazıda da sahibi olmadığın bir HTTP client'ı için doğru seam'in nerede olduğunu ele alıyoruz. Bu yazının geri kalanında interface'ler üzerinden üç kütüphaneyi aynı senaryolarla karşılaştırıyoruz.

Test Edilen Kod ve NSubstitute ile Beş Senaryo

Ödeme akışı: ödeme sağlayıcısını çağır, zaman aşımında bir kez yeniden dene, onaylanırsa makbuz gönder:

public sealed class CheckoutService(IPaymentGateway payments, IReceiptSender receipts)
{
    public async Task<CheckoutResult> PayAsync(
        Guid orderId, decimal amount, string email, string cardToken, CancellationToken ct)
    {
        PaymentRequest request = new(orderId, amount, "EUR", cardToken);
        PaymentResult result;
        try
        {
            result = await payments.ChargeAsync(request, ct);
        }
        catch (TimeoutException)
        {
            // Ödeme sağlayıcısı zaman aşımında bir kez yeniden dene
            result = await payments.ChargeAsync(request, ct);
        }

        if (!result.Approved)
        {
            return CheckoutResult.Declined;
        }

        await receipts.SendAsync(email, result.TransactionId!, amount, ct);
        return CheckoutResult.Paid;
    }
}

NSubstitute ile testler:

public sealed class NSubstituteCheckoutTests
{
    private readonly IPaymentGateway payments = Substitute.For<IPaymentGateway>();
    private readonly IReceiptSender receipts = Substitute.For<IReceiptSender>();
    private readonly Guid orderId = Guid.NewGuid();

    [Fact]
    public async Task Approved_payment_charges_amount_in_euro_and_sends_receipt()
    {
        PaymentRequest? captured = null;
        payments.ChargeAsync(Arg.Do<PaymentRequest>(r => captured = r), Arg.Any<CancellationToken>())
            .Returns(new PaymentResult(true, "tx-1", null));
        CheckoutService sut = new(payments, receipts);

        await sut.PayAsync(orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken);

        Assert.Equal(new PaymentRequest(orderId, 96m, "EUR", "tok_visa"), captured);
        await receipts.Received(1).SendAsync("ada@example.com", "tx-1", 96m, Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task Timeout_is_retried_once()
    {
        payments.ChargeAsync(Arg.Any<PaymentRequest>(), Arg.Any<CancellationToken>())
            .Returns(
                _ => throw new TimeoutException(),
                _ => new PaymentResult(true, "tx-2", null));
        CheckoutService sut = new(payments, receipts);

        CheckoutResult result = await sut.PayAsync(
            orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken);

        Assert.Equal(CheckoutResult.Paid, result);
        await payments.Received(2).ChargeAsync(Arg.Any<PaymentRequest>(), Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task Declined_payment_sends_no_receipt()
    {
        payments.ChargeAsync(Arg.Is<PaymentRequest>(r => r.Amount > 0), Arg.Any<CancellationToken>())
            .Returns(new PaymentResult(false, null, "insufficient_funds"));
        CheckoutService sut = new(payments, receipts);

        CheckoutResult result = await sut.PayAsync(
            orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken);

        Assert.Equal(CheckoutResult.Declined, result);
        await receipts.DidNotReceive().SendAsync(
            Arg.Any<string>(), Arg.Any<string>(), Arg.Any<decimal>(), Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task Receipt_is_sent_after_the_charge()
    {
        payments.ChargeAsync(Arg.Any<PaymentRequest>(), Arg.Any<CancellationToken>())
            .Returns(new PaymentResult(true, "tx-3", null));
        CheckoutService sut = new(payments, receipts);

        await sut.PayAsync(orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken);

        Received.InOrder(() =>
        {
            payments.ChargeAsync(Arg.Any<PaymentRequest>(), Arg.Any<CancellationToken>());
            receipts.SendAsync("ada@example.com", "tx-3", 96m, Arg.Any<CancellationToken>());
        });
    }

    [Fact]
    public async Task Gateway_exception_other_than_timeout_propagates()
    {
        payments.ChargeAsync(Arg.Any<PaymentRequest>(), Arg.Any<CancellationToken>())
            .ThrowsAsync(new HttpRequestException("503 Service Unavailable"));
        CheckoutService sut = new(payments, receipts);

        await Assert.ThrowsAsync<HttpRequestException>(() => sut.PayAsync(
            orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken));
    }
}

Birkaç ayrıntı. Argument matching: Arg.Any<T>() her değeri, Arg.Is<T>(predicate) koşulu sağlayanı kabul eder. Argüman yakalama: Arg.Do<T> çağrı geldiğinde bir action çalıştırır; yakalanan PaymentRequest record olduğu için tek bir Assert.Equal ile dört alanı birden karşılaştırabildik. Ardışık dönüş: Returns'a birden fazla değer ya da fonksiyon verilir; ilk çağrıda ilki, ikincide ikincisi. Async exception: NSubstitute.ExceptionExtensions'taki ThrowsAsync hatalı bir Task döner. Async method'lar için ayrıca bir şey yapmak gerekmiyor: Returns(value) değeri tamamlanmış bir Task'a sarıyor.

Aynı Senaryolar Moq ve FakeItEasy ile

Moq'ta her şey Mock<T> üzerinden, lambda ile kurulur ve .Object ile kullanılır:

[Fact]
public async Task Approved_payment_charges_amount_in_euro_and_sends_receipt()
{
    PaymentRequest? captured = null;
    payments.Setup(p => p.ChargeAsync(It.IsAny<PaymentRequest>(), It.IsAny<CancellationToken>()))
        .Callback<PaymentRequest, CancellationToken>((r, _) => captured = r)
        .ReturnsAsync(new PaymentResult(true, "tx-1", null));
    CheckoutService sut = new(payments.Object, receipts.Object);

    await sut.PayAsync(orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken);

    Assert.Equal(new PaymentRequest(orderId, 96m, "EUR", "tok_visa"), captured);
    receipts.Verify(r => r.SendAsync("ada@example.com", "tx-1", 96m, It.IsAny<CancellationToken>()), Times.Once);
}

[Fact]
public async Task Timeout_is_retried_once()
{
    payments.SetupSequence(p => p.ChargeAsync(It.IsAny<PaymentRequest>(), It.IsAny<CancellationToken>()))
        .ThrowsAsync(new TimeoutException())
        .ReturnsAsync(new PaymentResult(true, "tx-2", null));
    CheckoutService sut = new(payments.Object, receipts.Object);

    CheckoutResult result = await sut.PayAsync(
        orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken);

    Assert.Equal(CheckoutResult.Paid, result);
    payments.Verify(p => p.ChargeAsync(It.IsAny<PaymentRequest>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
}

[Fact]
public async Task Declined_payment_sends_no_receipt()
{
    payments.Setup(p => p.ChargeAsync(It.Is<PaymentRequest>(r => r.Amount > 0), It.IsAny<CancellationToken>()))
        .ReturnsAsync(new PaymentResult(false, null, "insufficient_funds"));
    CheckoutService sut = new(payments.Object, receipts.Object);

    CheckoutResult result = await sut.PayAsync(
        orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken);

    Assert.Equal(CheckoutResult.Declined, result);
    receipts.VerifyNoOtherCalls();
}

FakeItEasy'de giriş noktası A.CallTo; argument matcher'lar A<T>._ ve A<T>.That.Matches(...). Sıra doğrulaması .Then(...) ile zincirleniyor:

[Fact]
public async Task Approved_payment_charges_amount_in_euro_and_sends_receipt()
{
    A.CallTo(() => payments.ChargeAsync(A<PaymentRequest>._, A<CancellationToken>._))
        .Returns(new PaymentResult(true, "tx-1", null));
    CheckoutService sut = new(payments, receipts);

    await sut.PayAsync(orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken);

    A.CallTo(() => payments.ChargeAsync(new PaymentRequest(orderId, 96m, "EUR", "tok_visa"), A<CancellationToken>._))
        .MustHaveHappenedOnceExactly()
        .Then(A.CallTo(() => receipts.SendAsync("ada@example.com", "tx-1", 96m, A<CancellationToken>._))
            .MustHaveHappenedOnceExactly());
}

[Fact]
public async Task Timeout_is_retried_once()
{
    A.CallTo(() => payments.ChargeAsync(A<PaymentRequest>._, A<CancellationToken>._))
        .Throws(new TimeoutException()).Once()
        .Then.Returns(new PaymentResult(true, "tx-2", null));
    CheckoutService sut = new(payments, receipts);

    CheckoutResult result = await sut.PayAsync(
        orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken);

    Assert.Equal(CheckoutResult.Paid, result);
    A.CallTo(() => payments.ChargeAsync(A<PaymentRequest>._, A<CancellationToken>._))
        .MustHaveHappenedTwiceExactly();
}

[Fact]
public async Task Declined_payment_sends_no_receipt()
{
    A.CallTo(() => payments.ChargeAsync(A<PaymentRequest>.That.Matches(r => r.Amount > 0), A<CancellationToken>._))
        .Returns(new PaymentResult(false, null, "insufficient_funds"));
    CheckoutService sut = new(payments, receipts);

    CheckoutResult result = await sut.PayAsync(
        orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken);

    Assert.Equal(CheckoutResult.Declined, result);
    A.CallTo(receipts).MustNotHaveHappened();
}

Üç test class'ı birlikte 12 test, hepsi yeşil. FakeItEasy'nin ilk testinde ödeme isteğini yakalamak yerine doğrudan record eşitliğiyle doğruladık: argüman olarak verilen record, gelen record'la Equals ile karşılaştırılıyor. Bu NSubstitute ve Moq'ta da çalışır ve çoğu durumda argüman yakalamaktan daha okunur.

Çağrı Sayısı, Sırası ve Hata Mesajları

Bir mocking kütüphanesini seçerken en az bakılan ama en çok okunan şey, doğrulama kaldığında verdiği mesaj. Makbuzun 95 EUR ile gönderilmesini bekleyen (gerçekte 96 gönderiliyor) aynı doğrulamanın üç kütüphanedeki çıktısı:

NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching:
	SendAsync("ada@example.com", "tx-1", 95, any CancellationToken)
Actually received no matching calls.
Received 1 non-matching call (non-matching arguments indicated with '*' characters):
	SendAsync("ada@example.com", "tx-1", *96*, System.Threading.CancellationToken)

Moq.MockException :
Expected invocation on the mock once, but was 0 times: x => x.SendAsync("ada@example.com", "tx-1", 95, It.IsAny<CancellationToken>())
Performed invocations:
   Mock<IReceiptSender:1> (x):
      IReceiptSender.SendAsync("ada@example.com", "tx-1", 96, CancellationToken)

FakeItEasy.ExpectationException :
  Assertion failed for the following call:
    Bookstore.Checkout.IReceiptSender.SendAsync(email: "ada@example.com", transactionId: "tx-1", amount: 95, ct: <Ignored>)
  Expected to find it once exactly but didn't find it among the calls:
    1: Bookstore.Checkout.IReceiptSender.SendAsync(
          email: "ada@example.com",
          transactionId: "tx-1",
          amount: 96,
          ct: System.Threading.CancellationToken)

Üçü de gerçekleşen çağrıyı gösteriyor. NSubstitute farklı argümanı yıldızla işaretliyor; FakeItEasy parametre adlarını yazıyor. Sıra doğrulaması için NSubstitute'ta Received.InOrder, FakeItEasy'de .Then(...) var; Moq'ta MockSequence mevcut, ama strict mock'larla birlikte kullanılmak üzere tasarlandığı için pratikte daha az görülüyor. Sıra doğrulamasını gerçekten iş kuralı olan yerde kullan: "önce para çekilir, sonra makbuz gönderilir" bir iş kuralı; "önce müşteri sorgulanır, sonra stok" çoğu zaman implementation detayı.

Strict ve Loose Davranış

Varsayılan olarak üç kütüphane de loose: kurulmamış bir çağrı varsayılan değer döner (null, 0, tamamlanmış Task; NSubstitute ve FakeItEasy interface dönen method'lar için otomatik olarak yeni bir sahte de üretebiliyor). Moq ve FakeItEasy strict modu destekliyor; kurulmamış her çağrı exception fırlatır:

[Fact]
public async Task Strict_mock_rejects_unconfigured_call()
{
    Mock<IPaymentGateway> strict = new(MockBehavior.Strict);
    CheckoutService sut = new(strict.Object, receipts.Object);

    await Assert.ThrowsAsync<MockException>(() => sut.PayAsync(
        orderId, 96m, "ada@example.com", "tok_visa", TestContext.Current.CancellationToken));
}

Exception'ın mesajı: IPaymentGateway.ChargeAsync(PaymentRequest { ... Amount = 96, Currency = EUR, CardToken = tok_visa }, CancellationToken) invocation failed with mock behavior Strict. All invocations on the mock must have a corresponding setup. NSubstitute'ta strict mod yok; kütüphanenin tercihi bu.

Strict mock çekici görünür ("beklenmeyen hiçbir şey olmasın"), ama 01. yazıdaki sorunu büyütür: test, collaborator'la yapılan bütün konuşmayı sözleşmeye çevirir ve servise eklenen her yeni sorgu bütün testleri kırar. Loose varsayılanı koru; gerçekten "bu çağrı asla olmamalı" demek istediğin yerde DidNotReceive, Times.Never ya da MustNotHaveHappened ile bunu açıkça yaz.

Üç kütüphanenin temel API'si, lisansı (BSD (Berkeley Software Distribution) ya da MIT (Massachusetts Institute of Technology) lisansı) ve NuGet'teki hedef framework'leri:

KonuNSubstitute 6.2.0Moq 4.21.0FakeItEasy 9.0.1
OluşturmaSubstitute.For<T>()new Mock<T>() + .ObjectA.Fake<T>()
Setupx.Method(...).Returns(...)Setup(x => ...).ReturnsAsync(...)A.CallTo(() => ...).Returns(...)
DoğrulamaReceived(n)Verify(..., Times...)MustHaveHappened...()
Strict modYokMockBehavior.Stricto => o.Strict()
LisansBSD-3-ClauseBSD-3-ClauseMIT
Hedefler (NuGet)net8.0, netstandard2.0net462, netstandard2.0/2.1, net6.0net462, net8.0, net10.0

NSubstitute 6.0 Temmuz 2026'da çıktı ve hedeflerini net8.0 ile netstandard2.0'a indirdi. Moq'un 4.21.0'ı Eylül 2026'da geldi; ondan önceki son sürüm 2024'teki 4.20.72'ydi. FakeItEasy 9.0 Ocak 2026'da net10.0 hedefini ekledi.

Moq'un geçmişinde her .NET ekibinin hatırlaması gereken bir olay var. Ağustos 2023'te çıkan 4.20.0, kapalı kaynaklı SponsorLink'i pakete ekledi. SponsorLink build sırasında geliştiricinin git e-posta adresinin hash'ini alıp sponsorluk durumunu kontrol ediyordu. Topluluğun gizlilik tepkisi üzerine 9 Ağustos 2023'teki 4.20.2'de kaldırıldı; sürüm notu, kaldırma nedeni olarak macOS'ta restore'u bozmasını ve e-posta hash'lemesiyle ilgili gizlilik kaygılarını sayıyor. Ders Moq'a özel değil: test bağımlılıkları da build makinende ve CI'da (Continuous Integration) kod çalıştırır. Bağımlılık güncellemelerini otomatik birleştirmek yerine sürüm notlarını oku, kritik paketlerin sürümünü sabitle ve lisans ya da davranış değişikliği olan güncellemeyi bilinçli al. 03. yazıdaki FluentAssertions lisans değişikliği aynı dersin başka bir yüzü.

Hangisini seçmeli? Üçü de olgun ve aynı motoru kullanıyor; seçim büyük ölçüde syntax tercihi. Bu seride NSubstitute kullanıyoruz: syntax'ı en kısa olanı ve analyzer paketi, bu yazının başındaki hatayı derleme anında gösteriyor. Mevcut projende Moq varsa, sadece kütüphane değiştirmek için testleri yeniden yazmak nadiren değer.

Üretimde Dikkat

Analyzer'ı kur. NSubstitute.Analyzers non-virtual member'ı, yanlış yerde kullanılan argument matcher'ı ve Received'dan sonra unutulan çağrıyı derleme anında yakalar. Uyarıları TreatWarningsAsErrors ile hataya çevirmek, Önce Tahmin Et'teki testin hiç yazılamaması demek.

Setup'ı testin yanında tut. Her test class'ında yirmi satırlık bir ortak setup, hangi testin hangi davranışa güvendiğini gizler. Testin önemsediği dönüşü testin içinde kur; ortak olan yalnız double'ın oluşturulması olsun.

Mock'u yalnız sistem sınırında kullan. Ödeme ağ geçidi ve makbuz gönderimi sınırda; PriceCalculator ya da Order değil. Hangi bağımlılığın mock'lanması gerektiği bir sonraki yazının konusu.

Sık Düşülen Tuzaklar

Concrete class'ı mock'lamak. Önce Tahmin Et'teki örnek. Method'ları virtual yapmak derleme hatasını susturur, ama class'ın constructor'ı yine çalışır ve virtual olmayan her member gerçek davranışını sürdürür. Interface ya da bir adapter daha sağlam.

Her testte uzun setup zinciri. Bir testte beş Setup ve dört Verify varsa, test servisin değil mock'ların davranışını anlatıyordur. Bu genelde ya servisin çok fazla collaborator'ı olduğunu ya da testin implementation detayı doğruladığını gösterir.

Kütüphaneyi test kodu boyunca dağıtmak. Aynı projede üç kütüphane kullanmak (bu yazıda olduğu gibi) yalnız karşılaştırma için mantıklı. Gerçek bir projede bu, her geliştiricinin üç syntax öğrenmesi ve aynı dosyada Times gibi isim çakışmaları demek; Moq ile FakeItEasy'yi aynı dosyada using ettiğimde 'Times' is an ambiguous reference between 'FakeItEasy.Times' and 'Moq.Times' derleme hatası aldım.

Async method'da Returns ile senkron exception. Task dönen bir method'un hatası normalde hatalı bir Task olarak gelir. Testte senkron fırlatılan bir exception, await'ten önce yakalanan hatalarla farklı davranabilir. Async method'lar için ThrowsAsync kullanmak production'daki davranışa daha yakın.

Küçük Bir Deneme

★☆☆ ShippingRateClient için bir IShippingRates interface'i çıkar, CheckoutService'e kargo ücretini ekle ve Önce Tahmin Et'teki testi interface üzerinden yeniden yaz. NS1000 (NSubstitute.Analyzers'ın intercept edilemeyen member uyarısı) kayboldu mu?

★★☆ Timeout_is_retried_once'ı genişlet: iki ardışık zaman aşımında servis ne yapmalı? Üç kütüphaneden biriyle iki TimeoutException'ı ardışık kur ve servisin exception'ı dışarı verdiğini doğrula.

★★★ Moq'taki Declined_payment_sends_no_receipt testini MockBehavior.Strict ile yeniden yaz. Testi yeşile döndürmek için kaç setup eklemen gerekti? Sonra CheckoutService'e bir log satırı ekle; strict test ne oldu?

Bir sonraki yazıda soruyu tersine çeviriyoruz: mock ne zaman kullanılmamalı? Output, state ve communication tabanlı test stilleri, sistem sınırı, HttpClient için HttpMessageHandler stub'ı ve "sahibi olmadığını mock'lama" kuralı.