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

NSubstitute Rehberi: Returns, Arg Matcher'lar, When..Do, Received, Raise, ForPartsOf ve Analyzer

Mert Özen 15 Kas 2026 12 dk 3 görüntülenme
NSubstitute Rehberi: Returns, Arg Matcher'lar, When..Do, Received, Raise, ForPartsOf ve Analyzer

NSubstitute'ta Returns son yakalanan çağrıya bağlanır; class'ı substitute edip non-virtual bir method'u kurarsan, o method'un içinden çağrılan virtual member yanlışlıkla kurulur ve test exception yerine yanlış bir sayıyla kalır. Returns'ün sabit, ardışık ve hesaplanan biçimleri, out parametreleri, When..Do, Received ve gerçek hata mesajı, Raise.EventWith, recursive mock'lar, ForPartsOf ile Configure() ve analyzer kuralları.

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

Önce Tahmin Et

07. yazı üç mocking kütüphanesini aynı senaryolarla karşılaştırdı ve seride NSubstitute'u seçtik. Bu yazı karşılaştırmayı tekrar etmiyor; NSubstitute'un her gün kullanılan API'sini sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) Payments ve Notifications module'leri üzerinden tek tek geçiyor. Önce bir soru. Ödeme sağlayıcısı komisyonunu hesaplayan class interface'siz, concrete bir class:

public class PaymentFeeCalculator
{
    public decimal Fee(decimal amount, string provider) =>
        Math.Round(amount * ProviderFeeRate(provider), 2, MidpointRounding.AwayFromZero);

    // Gerçek oranlar sağlayıcı sözleşme tablosundan gelir
    public virtual decimal ProviderFeeRate(string provider) => provider switch
    {
        "card-gateway" => 0.025m,
        "bank-transfer" => 0.0m,
        _ => throw new KeyNotFoundException($"No fee contract for provider '{provider}'."),
    };
}

Yeni bir cüzdan sağlayıcısı için komisyonun 3 lira olacağını varsayan bir test:

[Fact]
public void Wallet_fee_is_three_lira()
{
    PaymentFeeCalculator calculator = Substitute.For<PaymentFeeCalculator>();
    calculator.Fee(200m, "wallet").Returns(3m);

    decimal fee = calculator.Fee(200m, "wallet");

    Assert.Equal(3m, fee);
}

Fee virtual değil. 07. yazıda non-virtual bir method'a Returns verince NSubstitute'un "Could not find a call to return from" diye exception fırlattığını görmüştük. Burada da aynısı mı olur?

Cevap: Returns Son Yakalanan Çağrıya Bağlanıyor

Bu sefer exception yok. Test kalıyor ve mesaj şaşırtıcı:

Assert.Equal() Failure: Values differ
Expected: 3
Actual:   600

Sırayla ne olduğuna bakalım. calculator.Fee(200m, "wallet") non-virtual olduğu için proxy'ye takılmadan gerçek koduyla çalıştı. Gerçek Fee, içeride virtual ProviderFeeRate("wallet")'ı çağırdı; bu çağrı proxy'ye takıldı ve kurulmamış olduğu için 0 döndü. Returns(3m) ise "son yakalanan çağrı" hangisiyse ona bağlanıyor: ProviderFeeRate("wallet"). Artık oran 3, yani %300; Fee 200 × 3 = 600 dönüyor. Hiçbir exception yok, yalnız yanlış kurulmuş bir test ve yanlış bir sayı. NSubstitute.Analyzers bu satırı derlemede işaretliyor:

Probe.cs(9,9): warning NS1000: Member Fee can not be intercepted. Only interface members and virtual, overriding, and abstract members can be intercepted.

Bir class'ın gerçek davranışını koruyup yalnız bir parçasını değiştirmek istiyorsan doğru araç Substitute.ForPartsOf<T>(). Burada da bir tuzak var: partial substitute'ta setup satırındaki çağrı gerçek method'u çalıştırıyor. calculator.ProviderFeeRate("wallet").Returns(0.015m) yazınca test, sözleşmesi olmayan sağlayıcı için gerçek kodun fırlattığı KeyNotFoundException : No fee contract for provider 'wallet'. ile setup satırında kaldı. Çözüm NSubstitute.Extensions namespace'indeki Configure(): ondan sonraki çağrı gerçek koda gitmeden yalnız kurulum olarak kaydediliyor.

[Fact]
public void Fee_uses_the_rate_of_a_new_provider()
{
    PaymentFeeCalculator calculator = Substitute.ForPartsOf<PaymentFeeCalculator>();
    calculator.Configure().ProviderFeeRate("wallet").Returns(0.015m);

    Assert.Equal(3.00m, calculator.Fee(200m, "wallet"));
    Assert.Equal(5.00m, calculator.Fee(200m, "card-gateway"));
}

Fee ve "card-gateway" oranı gerçek, yalnız "wallet" oranı taklit. ForPartsOf'u yeni kodda tasarım aracı olarak değil, interface çıkaramadığın eski kodda geçici bir seam (kodu değiştirmeden davranışı test için değiştirebildiğin nokta, 09. yazı) olarak düşün.

Substitute.For ve Returns: Sabit, Ardışık ve Hesaplanan Değerler

Ödeme akışı: fraud kontrolü, para birimine göre gateway seçimi, zaman aşımında tek bir retry, audit kaydı ve onaylanan ödemede müşteriye e-posta:

public sealed class PaymentService(
    IPaymentGatewayFactory gateways, IFraudCheck fraud, IPaymentAuditLog audit, INotificationSender notifications)
{
    public const int FraudThreshold = 80;

    public async Task<PaymentOutcome> PayAsync(
        Guid orderId, decimal amount, string currency, string email, string cardToken, CancellationToken ct)
    {
        if (fraud.TryScore(email, amount, out int score) && score >= FraudThreshold)
        {
            audit.Write($"blocked {orderId} score={score}");
            return PaymentOutcome.Blocked;
        }

        IPaymentGateway gateway = gateways.For(currency);
        ChargeRequest request = new(orderId, amount, currency, cardToken);
        ChargeResult result;
        try
        {
            result = await gateway.ChargeAsync(request, ct);
        }
        catch (TimeoutException)
        {
            audit.Write($"timeout {orderId} via {gateway.ProviderName}, retrying");
            result = await gateway.ChargeAsync(request, ct);
        }

        if (!result.Approved)
        {
            audit.Write($"declined {orderId} {result.DeclineCode}");
            return PaymentOutcome.Declined;
        }

        audit.Write($"charged {orderId} {result.TransactionId}");
        string body = string.Create(CultureInfo.InvariantCulture, $"We received {amount:0.00} {currency} for your order.");
        await notifications.SendAsync(new EmailMessage(email, "Payment received", body), ct);
        return PaymentOutcome.Paid;
    }
}

Test class'ı substitute'ları field olarak oluşturuyor; xUnit her test için yeni instance açtığından (05. yazı) her test temiz substitute'larla başlıyor:

public sealed class PaymentServiceTests
{
    private static readonly ChargeResult Approved = new(true, "tx-1001", null);

    private readonly IPaymentGatewayFactory gateways = Substitute.For<IPaymentGatewayFactory>();
    private readonly IPaymentGateway gateway = Substitute.For<IPaymentGateway>();
    private readonly IFraudCheck fraud = Substitute.For<IFraudCheck>();
    private readonly IPaymentAuditLog audit = Substitute.For<IPaymentAuditLog>();
    private readonly INotificationSender notifications = Substitute.For<INotificationSender>();
    private readonly PaymentService sut;
    private readonly Guid orderId = Guid.Parse("8c6f1c9e-4f1a-4d7e-9a43-2a4f3c1d0b11");

    public PaymentServiceTests()
    {
        gateways.For("TRY").Returns(gateway);
        gateway.ProviderName.Returns("card-gateway");
        sut = new PaymentService(gateways, fraud, audit, notifications);
    }

    private Task<PaymentOutcome> Pay(decimal amount) =>
        sut.PayAsync(orderId, amount, "TRY", "ada@example.com", "card-token-test", TestContext.Current.CancellationToken);

    [Fact]
    public async Task Gateway_timeout_is_retried_once()
    {
        gateway.ChargeAsync(Arg.Any<ChargeRequest>(), Arg.Any<CancellationToken>())
            .Returns(
                Task.FromException<ChargeResult>(new TimeoutException()),
                Task.FromResult(Approved));

        PaymentOutcome outcome = await Pay(349.90m);

        Assert.Equal(PaymentOutcome.Paid, outcome);
        await gateway.Received(2).ChargeAsync(Arg.Any<ChargeRequest>(), Arg.Any<CancellationToken>());
        audit.Received(1).Write("timeout 8c6f1c9e-4f1a-4d7e-9a43-2a4f3c1d0b11 via card-gateway, retrying");
    }

    [Fact]
    public async Task Amount_above_the_card_limit_is_declined()
    {
        gateway.ChargeAsync(Arg.Any<ChargeRequest>(), Arg.Any<CancellationToken>())
            .Returns(call => call.Arg<ChargeRequest>().Amount > 5_000m
                ? new ChargeResult(false, null, "limit_exceeded")
                : Approved);

        Assert.Equal(PaymentOutcome.Declined, await Pay(7_500m));
        Assert.Equal(PaymentOutcome.Paid, await Pay(349.90m));
        audit.Received(1).Write(Arg.Is<string>(e => e.EndsWith("limit_exceeded")));
    }
}

Returns'ün üç biçimi var. Sabit değer: gateway.ProviderName.Returns("card-gateway"); property'ler de method gibi kuruluyor. Ardışık değerler: Returns(first, second, ...) her çağrıda sıradakini dönüyor, liste bitince son değer tekrarlanıyor. Async method'da bunlar Task olabiliyor: ilk test, ilk çağrıda hatalı bir Task (zaman aşımı), ikincide onay dönüyor. 07. yazıdaki lambda'lı yazımla aynı sonuç, ama burada exception senkron fırlatılmıyor, production'daki gibi Task'ın içinde geliyor. Hesaplanan değer: Returns(call => ...) her çağrıda çalışıyor ve call.Arg<T>() ile argümanlara erişiyor; ikinci test tutara göre farklı yanıt veren bir gateway kuruyor. ReturnsForAnyArgs aynı kurulumu argümanlara hiç bakmadan yapıyor.

Exception için NSubstitute.ExceptionExtensions namespace'inde Throws ve ThrowsAsync var:

[Fact]
public async Task Gateway_error_other_than_timeout_is_not_retried()
{
    gateway.ChargeAsync(Arg.Any<ChargeRequest>(), Arg.Any<CancellationToken>())
        .ThrowsAsync(new HttpRequestException("503 Service Unavailable"));

    await Assert.ThrowsAsync<HttpRequestException>(() => Pay(349.90m));

    await gateway.Received(1).ChargeAsync(Arg.Any<ChargeRequest>(), Arg.Any<CancellationToken>());
}

Argument Matcher'lar ve out Parametreleri

Arg.Any<T>() her değeri, Arg.Is<T>(predicate) koşulu sağlayanı, Arg.Is(value) eşit olanı kabul ediyor; argümanı doğrudan yazmak da Arg.Is(value) ile aynı. Arg.Do<T>(action) argümanı yakalamak için (07. yazı). 6.1.0 referans eşitliğine bakan bir matcher ekledi (Arg.Same); Equals'ı override eden bir tipte "bu instance mı?" sorusu için. 6.0'da, C# 7 öncesi derleyiciler için yazılmış Arg.Compat API'si obsolete oldu.

Fraud kontrolü out parametresiyle skor dönüyor. out parametresini kurulumda doldurmak için call[index]'e atama yapıyorsun:

[Fact]
public async Task High_fraud_score_blocks_the_payment_before_charging()
{
    fraud.TryScore("ada@example.com", Arg.Any<decimal>(), out Arg.Any<int>())
        .Returns(call =>
        {
            call[2] = 91;
            return true;
        });

    PaymentOutcome outcome = await Pay(349.90m);

    Assert.Equal(PaymentOutcome.Blocked, outcome);
    gateways.DidNotReceiveWithAnyArgs().For(default!);
    audit.Received().Write("blocked 8c6f1c9e-4f1a-4d7e-9a43-2a4f3c1d0b11 score=91");
}

out Arg.Any<int>() "bu parametreye ne gelirse gelsin" demek. Index sıfırdan başlıyor ve out/ref parametreler de sayılıyor: email 0, amount 1, score 2. ref parametreler aynı şekilde, call[i] = ... ile değiştiriliyor. Bu ifade parametre sırasına bağlı; method imzası değişirse derleyici seni uyarmaz, test çalışma anında yanlış index'e yazar. Callback'i kısa tutmak bu yüzden önemli.

Void Method'lar: When..Do

Returns dönüş değeri olan çağrılar için. void bir method'a davranış eklemek ya da çağrıyı izlemek için When(...).Do(...) kullanılıyor:

[Fact]
public async Task Audit_entries_follow_the_payment_flow()
{
    List<string> entries = [];
    audit.When(a => a.Write(Arg.Any<string>())).Do(call => entries.Add(call.Arg<string>()));
    gateway.ChargeAsync(Arg.Any<ChargeRequest>(), Arg.Any<CancellationToken>())
        .Returns(Task.FromException<ChargeResult>(new TimeoutException()), Task.FromResult(Approved));

    await Pay(349.90m);

    Assert.Equal(
        [
            "timeout 8c6f1c9e-4f1a-4d7e-9a43-2a4f3c1d0b11 via card-gateway, retrying",
            "charged 8c6f1c9e-4f1a-4d7e-9a43-2a4f3c1d0b11 tx-1001",
        ],
        entries);
}

[Fact]
public async Task Failing_audit_log_stops_before_the_receipt()
{
    gateway.ChargeAsync(Arg.Any<ChargeRequest>(), Arg.Any<CancellationToken>()).Returns(Approved);
    audit.When(a => a.Write(Arg.Is<string>(e => e.StartsWith("charged"))))
        .Do(_ => throw new IOException("Audit store unavailable."));

    await Assert.ThrowsAsync<IOException>(() => Pay(349.90m));

    await notifications.DidNotReceive().SendAsync(Arg.Any<EmailMessage>(), Arg.Any<CancellationToken>());
}

İlk test audit kayıtlarını sırasıyla topluyor; aynı işi audit.Write(Arg.Do<string>(entries.Add)) ile de yapabilirdin. İkinci test void bir method'u yalnız belirli bir argümanda başarısız yapıyor: "charged" kaydı yazılamazsa müşteriye e-posta gitmiyor. Bu bir tasarım sorusunu da açıyor: audit kaydı yazılamadığında ödeme alınmış ama müşteri haberdar edilmemiş oluyor. Testin işi bu davranışı görünür kılmak; doğru davranışın ne olduğuna Payments module'ünün sahibi karar veriyor.

Received, DidNotReceive ve Çağrı Sayısı

[Fact]
public async Task Approved_payment_sends_one_receipt_to_the_customer()
{
    gateway.ChargeAsync(Arg.Any<ChargeRequest>(), Arg.Any<CancellationToken>()).Returns(Approved);

    PaymentOutcome outcome = await Pay(349.90m);

    Assert.Equal(PaymentOutcome.Paid, outcome);
    await notifications.Received(1).SendAsync(
        Arg.Is<EmailMessage>(m => m.To == "ada@example.com" && m.Body.Contains("349.90 TRY")),
        Arg.Any<CancellationToken>());
}

[Fact]
public async Task Each_payment_sends_its_own_receipt()
{
    gateway.ChargeAsync(Arg.Any<ChargeRequest>(), Arg.Any<CancellationToken>()).Returns(Approved);
    await Pay(349.90m);
    notifications.ClearReceivedCalls();

    await Pay(120.00m);

    await notifications.Received(1).SendAsync(
        Arg.Is<EmailMessage>(m => m.Body.Contains("120.00 TRY")), Arg.Any<CancellationToken>());
}

Received() en az bir, Received(n) tam n çağrı bekliyor; DidNotReceive() hiç çağrı olmamasını. ReceivedWithAnyArgs/DidNotReceiveWithAnyArgs argümanlara bakmıyor; fraud testinde gateways.DidNotReceiveWithAnyArgs().For(default!) "hiçbir gateway istenmedi" diyor. Async method'larda Received'ın döndüğü Task'ı await etmek şart değil ama okunurluğu ve analyzer'ları rahatlatıyor. ClearReceivedCalls() o ana kadarki çağrı kayıtlarını siliyor, kurulumları koruyor; ikinci test birinci ödemenin e-postasını görmeden ikincisini doğruluyor. Çağrı sırası için Received.InOrder var (07. yazı).

Doğrulama kalınca NSubstitute gelen çağrıları ve uyuşmayan argümanı yıldızla işaretliyor. E-postanın Alan'a gitmesini beklersek:

NSubstitute.Exceptions.ReceivedCallsException : Expected to receive exactly 1 call matching:
	SendAsync(m => (m.To == "alan@example.com"), any CancellationToken)
Actually received no matching calls.
Received 1 non-matching call (non-matching arguments indicated with '*' characters):
	SendAsync(*EmailMessage { To = ada@example.com, Subject = Payment received, Body = We received 349.90 TRY for your order. }*, System.Threading.CancellationToken)

Record kullanmanın faydası burada da görünüyor: EmailMessage'ın ToString'i bütün alanları yazıyor ve mesaj neyin gönderildiğini tam olarak söylüyor.

Küçük bir xUnit etkileşimi: DidNotReceiveWithAnyArgs().SendAsync(default!, default) gibi CancellationToken parametresine default verdiğim satırlarda xUnit analyzer'ı xUnit1051 uyarısını ("should use TestContext.Current.CancellationToken") verdi; argümanlar o çağrıda dikkate alınmasa da analyzer bunu bilmiyor. Arg.Any<CancellationToken>() ile yazmak iki aracı da memnun ediyor.

Event'ler, Property'ler ve Recursive Mock'lar

Gateway, ödeme kesinleştiğinde (sağlayıcının webhook'u geldiğinde) bir event yayınlıyor; SettlementTracker bunları topluyor. Event'i substitute üzerinden tetiklemek için Raise:

[Fact]
public void Settlements_raised_by_the_gateway_are_summed()
{
    IPaymentGateway gateway = Substitute.For<IPaymentGateway>();
    using SettlementTracker tracker = new(gateway);

    gateway.Settled += Raise.EventWith(new PaymentSettledEventArgs("tx-1001", 349.90m));
    gateway.Settled += Raise.EventWith(gateway, new PaymentSettledEventArgs("tx-1002", 120.00m));

    Assert.Equal(["tx-1001", "tx-1002"], tracker.SettledTransactions);
    Assert.Equal(469.90m, tracker.SettledTotal);
}

+= syntax'ı garip görünüyor ama bilinçli: C#'ta bir event'i dışarıdan yalnız +=/-= ile anabiliyorsun, NSubstitute da bu ifadeyi "event'i tetikle" diye yorumluyor. Raise.EventWith(args) sender olarak substitute'un kendisini, Raise.EventWith(sender, args) verdiğin sender'ı kullanıyor; EventHandler<T> dışındaki delegate tipleri için Raise.Event<TDelegate>(...) var.

Kurulmamış member'lar varsayılan değer dönüyor, ama "varsayılan" tipine göre değişiyor. string property boş string, sayılar 0, interface dönen bir method ise otomatik olarak yeni bir substitute dönüyor (recursive mock). Bunu kullanan bir test:

[Fact]
public async Task Unconfigured_factory_returns_a_recursive_substitute()
{
    IPaymentGatewayFactory gateways = Substitute.For<IPaymentGatewayFactory>();
    gateways.For("TRY").ChargeAsync(Arg.Any<ChargeRequest>(), Arg.Any<CancellationToken>())
        .Returns(new ChargeResult(true, "tx-2001", null));
    PaymentService sut = new(
        gateways, Substitute.For<IFraudCheck>(), Substitute.For<IPaymentAuditLog>(), Substitute.For<INotificationSender>());

    PaymentOutcome outcome = await sut.PayAsync(
        Guid.NewGuid(), 120m, "TRY", "grace@example.com", "card-token-test", TestContext.Current.CancellationToken);

    Assert.Equal(PaymentOutcome.Paid, outcome);
    Assert.Same(gateways.For("TRY"), gateways.For("TRY"));
    Assert.NotSame(gateways.For("TRY"), gateways.For("EUR"));
}

gateways.For("TRY") her seferinde aynı otomatik substitute'u, farklı argümanla farklısını dönüyor; zincirin sonundaki ChargeAsync'i doğrudan kurabiliyorsun. Kullanışlı ama sınırı var: ChargeResult bir record (sealed class) olduğu için otomatik değeri null. ChargeAsync'i hiç kurmadığım bir testte Task tamamlanmış ama sonucu null geldi ve servis result.Approved satırında System.NullReferenceException ile kaldı. Kurulmamış bir collaborator'ın sessizce "makul" bir şey döneceğini varsayma; test kalınca ilk bakılacak yer, kurulmamış bir çağrı.

NSubstitute.Analyzers

NSubstitute'un hatalarının çoğu çalışma anında değil, yanlış kurulmuş ama yeşil bir testte ortaya çıkıyor. Analyzer paketi bunları derlemede yakalıyor. Bu yazı için yazdığım hatalı kodda gördüğüm kurallar:

warning NS1000: Member Fee can not be intercepted. Only interface members and virtual, overriding, and abstract members can be intercepted.
warning NS1004: Argument matcher used with a non-virtual member of a class.
warning NS2000: Can only substitute for parts of classes, not interfaces or delegates. Use Substitute.For<IPaymentAuditLog>() instead of Substitute.ForPartsOf<IPaymentAuditLog>() here.
warning NS2002: The number of arguments passed to NSubstitute.Substitute.For<Bookstore.Payments.PaymentFeeCalculator> do not match the number of constructor arguments for Bookstore.Payments.PaymentFeeCalculator. Check the constructors for Bookstore.Payments.PaymentFeeCalculator and make sure you have passed the required number of arguments.
warning NS5000: Unused received check. To fix, make sure there is a call after "Received". Correct: "sub.Received().SomeCall();". Incorrect: "sub.Received();"

NS5000 (Received()'dan sonra doğrulanacak çağrının yazılmadığı uyarısı) özellikle değerli: audit.Received(); satırı çalışma anında hiçbir şey yapmıyor ve test yeşil kalıyor; doğrulama yazdığını sanıyorsun ama yazmamışsın. Paketi PrivateAssets="all" ile test projesine ekle ve bu kuralları WarningsAsErrors ile hataya çevir. Analyzer her şeyi görmüyor: sealed bir class için Substitute.For<PaymentService>() yazınca yalnız constructor argümanı sayısıyla ilgili NS2002 (verilen argüman sayısı constructor'la uyuşmuyor) uyarısını aldım, sealed olmasıyla ilgili bir uyarı gelmedi. 1.0.17 Şubat 2024 tarihli, yani analyzer kütüphanenin 6.x'teki yeniliklerinden haberdar değil; yine de temel kurallar geçerli.

Üretimde Dikkat

Setup'ı testin önemsediği kadar tut. Constructor'da her test için geçerli olan tek kurulum gateways.For("TRY").Returns(gateway) ve ProviderName; ödeme sonucunu her test kendisi kuruyor. Ortak setup'a bir Returns(Approved) eklemek, "reddedilen ödeme" testini yazan kişinin o kurulumu ezmesini gerektirir ve iki kurulumdan hangisinin geçerli olduğu (sonuncusu) okuyana açık değildir.

Analyzer uyarılarını hata yap ve CI'da kontrol et. Bu yazıdaki en tehlikeli üç hata (Önce Tahmin Et'teki yanlış bağlanan Returns, NS5000'deki boş Received, kurulmamış çağrının null dönmesi) testi kırmızıya çevirmiyor; ilk ikisini analyzer, üçüncüsünü yalnız dikkatli bir code review yakalıyor.

Sürümü sabitle. NSubstitute 6.0.0 (Temmuz 2026) hedefleri net8.0 ve netstandard2.0'a indirdi ve public API'de nullability'yi açtı; 6.1.0 bu nullability değişikliğini kullanıcı kodunda sorun çıkardığı için geri aldı, 6.2.0 (11 Ağustos 2026) generic çağrı eşleştirmeyi ve C# 13 params collection desteğini getirdi. İki ay içinde gelip giden bir değişiklik, test bağımlılıklarının da otomatik güncellenmemesi için iyi bir örnek. Lisansı BSD (kısıtlaması az, ticari kullanıma açık bir açık kaynak lisans ailesi) ailesinden BSD-3-Clause.

Sık Düşülen Tuzaklar

Argument matcher'ı bir substitute çağrısı dışında kullanmak. Matcher'lar çağrıldıkları anda NSubstitute'un içinde bir sıraya giriyor ve bir sonraki substitute çağrısına bağlanıyor. Matcher'ı değişkene alıp sonra kullanmak, araya başka çağrılar girerse onu yanlış yere bağlar:

EmailMessage toAda = Arg.Is<EmailMessage>(m => m.To == "ada@example.com");
gateways.For("TRY").Returns(gateway);

Bu kodla test RedundantArgumentMatcherException : Some argument specifications (e.g. Arg.Is, Arg.Any) were left over after the last call. ile kaldı. Mesaj uzun ama sonunda işe yarar bir tanı veriyor ("Remaining (non-bound) argument specifications: m => (m.To == "ada@example.com")") ve sebebin önceki bir testte olabileceğini de söylüyor. Matcher'ı her zaman doğrudan Returns, Received ya da When içindeki çağrıda yaz.

Class'ı substitute etmek. Önce Tahmin Et'teki örnek: non-virtual method'un gerçeği çalışıyor, içindeki virtual çağrılar yakalanıyor ve Returns beklemediğin bir member'a bağlanıyor. Interface ya da ForPartsOf + Configure() kullan; analyzer'ın NS1000 (intercept edilemeyen member) uyarısını hiçbir zaman görmezden gelme.

out parametre index'ini yanlış saymak. call[2] parametre sırasına bağlı; TryScore'a yeni bir parametre eklendiğinde test derlenir ama skor yanlış yere yazılır. Imza değişikliklerinde bu tür callback'leri gözden geçir.

Küçük Bir Deneme

★☆☆ Wallet_fee_is_three_lira'yı ForPartsOf ve Configure() ile düzelt. Configure()'u kaldırıp çalıştır: hangi satırda, hangi exception'la kalıyor?

★★☆ PaymentService'e reddedilen ödemede müşteriye "payment declined" e-postası gönderen bir dal ekle. Testte Received(1) ile e-postanın içeriğini, DidNotReceive ile onay e-postasının gitmediğini doğrula.

★★★ SettlementTracker'a aynı TransactionId ile ikinci kez gelen event'i yok sayan bir kural ekle ve bunu Raise.EventWith ile test et. Aynı event'i üç kez tetiklediğinde toplam doğru mu?

Bir sonraki yazıda aynı API ailesinin Moq karşılığını, legacy Ordering testleri üzerinden işliyoruz: Setup, SetupSequence, It matcher'ları, Callback, Verify, strict mock, Protected() ve Mock.Of, yanında FakeItEasy karşılıklarıyla.