Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 055 / 100

Mocking with NSubstitute: a hands-on guide for engineers

Readable test doubles with minimal ceremony.

Expona Admin9 min read

Introduction

When unit tests start calling external services, they stop being unit tests. Mocking with NSubstitute gives you readable test doubles with minimal ceremony—letting you isolate the code under test and verify behavior without touching real email servers, payment gateways, or system clocks. In this hands-on guide for engineers, we'll walk through the patterns that make NSubstitute the go-to choice for teams building maintainable test suites across .NET and ASP.NET Core applications.

Substitute.For<IEmailSender>() and you are done

NSubstitute's greatest strength is its simplicity. Creating a test double takes one line:

csharp
var emailSender = Substitute.For<IEmailSender>();

That's it. No setup ceremony, no verbose configuration, no fluent builder chains. You have a working substitute that implements IEmailSender and is ready to use immediately.

Here's a realistic scenario. We're testing a user registration service that sends a welcome email:

csharp
public interface IEmailSender
{
    Task SendAsync(string to, string subject, string body);
}

public class UserRegistrationService
{
    private readonly IUserRepository _userRepo;
    private readonly IEmailSender _emailSender;

    public UserRegistrationService(IUserRepository userRepo, IEmailSender emailSender)
    {
        _userRepo = userRepo;
        _emailSender = emailSender;
    }

    public async Task RegisterAsync(string email, string password)
    {
        var user = new User { Email = email, PasswordHash = Hash(password) };
        await _userRepo.AddAsync(user);
        await _emailSender.SendAsync(email, "Welcome!", "Thank you for joining.");
    }
}

[Test]
public async Task RegisterAsync_SendsWelcomeEmail()
{
    // Arrange
    var emailSender = Substitute.For<IEmailSender>();
    var userRepo = Substitute.For<IUserRepository>();
    var service = new UserRegistrationService(userRepo, emailSender);

    // Act
    await service.RegisterAsync("alice@example.com", "secret123");

    // Assert
    await emailSender.Received(1).SendAsync(
        "alice@example.com",
        "Welcome!",
        "Thank you for joining."
    );
}

One line creates the substitute. No interfaces to mock, no abstract classes to subclass, no record-and-replay setup. The test reads naturally and focuses on the behavior we care about.

Returns() for stubs, Received() for verification

NSubstitute gives you two core verbs: Returns() for configuring behavior and Received() for verifying calls. Understanding when to use each keeps your tests readable and focused.

Returns() configures stub behavior. When your code needs a substitute to return data, you tell it what to return:

csharp
[Test]
public async Task RegisterAsync_WithExistingEmail_ThrowsException()
{
    // Arrange
    var emailSender = Substitute.For<IEmailSender>();
    var userRepo = Substitute.For<IUserRepository>();
    
    // Configure the stub to throw when we try to add a duplicate user
    userRepo.AddAsync(Arg.Any<User>())
        .Returns(Task.FromException(
            new InvalidOperationException("Email already registered")
        ));

    var service = new UserRegistrationService(userRepo, emailSender);

    // Act & Assert
    Assert.ThrowsAsync<InvalidOperationException>(
        () => service.RegisterAsync("bob@example.com", "pass456")
    );
}

Received() verifies interaction. After your code runs, you assert that specific methods were called with specific arguments:

csharp
[Test]
public async Task RegisterAsync_CallsRepositoryOnce()
{
    // Arrange
    var emailSender = Substitute.For<IEmailSender>();
    var userRepo = Substitute.For<IUserRepository>();
    var service = new UserRegistrationService(userRepo, emailSender);

    // Act
    await service.RegisterAsync("charlie@example.com", "xyz789");

    // Assert
    await userRepo.Received(1).AddAsync(Arg.Is<User>(u => u.Email == "charlie@example.com"));
}

You can verify that a method was called zero times (DidNotReceive()), exactly N times (Received(N)), or at least N times (Received(AtLeast: N)). This flexibility lets you enforce contracts without over-specifying implementation details.

Mock at boundaries: email, payment, clock

The golden rule: mock at external boundaries. These are the places where your code talks to systems outside your control. Email servers, payment processors, file storage, and system clocks are classic examples.

Consider a billing service that charges customers and sends a receipt email:

csharp
public interface IPaymentGateway
{
    Task<PaymentResult> ChargeAsync(string customerId, decimal amount);
}

public interface IEmailSender
{
    Task SendAsync(string to, string subject, string body);
}

public interface IClock
{
    DateTime UtcNow { get; }
}

public class BillingService
{
    private readonly IPaymentGateway _gateway;
    private readonly IEmailSender _emailSender;
    private readonly IClock _clock;
    private readonly IInvoiceRepository _invoiceRepo;

    public BillingService(
        IPaymentGateway gateway,
        IEmailSender emailSender,
        IClock clock,
        IInvoiceRepository invoiceRepo)
    {
        _gateway = gateway;
        _emailSender = emailSender;
        _clock = clock;
        _invoiceRepo = invoiceRepo;
    }

    public async Task BillCustomerAsync(string customerId, decimal amount, string email)
    {
        var paymentResult = await _gateway.ChargeAsync(customerId, amount);
        
        if (!paymentResult.Success)
            throw new PaymentFailedException(paymentResult.ErrorCode);

        var invoice = new Invoice
        {
            CustomerId = customerId,
            Amount = amount,
            CreatedAt = _clock.UtcNow,
            TransactionId = paymentResult.TransactionId
        };
        
        await _invoiceRepo.SaveAsync(invoice);
        await _emailSender.SendAsync(
            email,
            "Receipt",
            $"Thank you for your payment of ${amount}. ID: {invoice.Id}"
        );
    }
}

[Test]
public async Task BillCustomerAsync_SuccessfulCharge_SendsReceiptAndSavesInvoice()
{
    // Arrange
    var gateway = Substitute.For<IPaymentGateway>();
    var emailSender = Substitute.For<IEmailSender>();
    var clock = Substitute.For<IClock>();
    var invoiceRepo = Substitute.For<IInvoiceRepository>();

    var now = new DateTime(2024, 1, 15, 10, 30, 0, DateTimeKind.Utc);
    clock.UtcNow.Returns(now);

    var paymentResult = new PaymentResult
    {
        Success = true,
        TransactionId = "txn_12345"
    };
    gateway.ChargeAsync("cust_001", 99.99m)
        .Returns(Task.FromResult(paymentResult));

    var service = new BillingService(gateway, emailSender, clock, invoiceRepo);

    // Act
    await service.BillCustomerAsync("cust_001", 99.99m, "user@example.com");

    // Assert
    await gateway.Received(1).ChargeAsync("cust_001", 99.99m);
    await invoiceRepo.Received(1).SaveAsync(
        Arg.Is<Invoice>(inv => inv.TransactionId == "txn_12345")
    );
    await emailSender.Received(1).SendAsync(
        "user@example.com",
        "Receipt",
        Arg.Any<string>()
    );
}

Notice how we mock the payment gateway (external service), the email sender (external service), the clock (non-deterministic), and the invoice repository (persistence boundary). Each substitute lets us test the service's orchestration logic in isolation, without actually charging cards or sending emails.

Never mock what you own and can run cheaply

The flip side of mocking at boundaries is this principle: do not mock code you own and can run cheaply in your test. If you own the code, you can run it. If running it costs nothing (in time or side effects), do it.

A common mistake is over-mocking. Consider this domain model:

csharp
public class Invoice
{
    public string Id { get; set; }
    public string CustomerId { get; set; }
    public decimal Amount { get; set; }
    public List<LineItem> LineItems { get; set; } = new();
    
    public decimal CalculateTotal()
    {
        return LineItems.Sum(li => li.Quantity * li.UnitPrice);
    }
    
    public decimal CalculateTax(decimal taxRate)
    {
        return CalculateTotal() * taxRate;
    }
}

public class LineItem
{
    public string Description { get; set; }
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
}

You should never mock Invoice or LineItem. These are classes you own; they're fast to instantiate; they have no side effects. Test them directly:

csharp
[Test]
public void CalculateTotal_WithLineItems_ReturnsSum()
{
    // Arrange - use real objects, not substitutes
    var invoice = new Invoice
    {
        Id = "inv_001",
        LineItems = new()
        {
            new LineItem { Description = "Widget", Quantity = 2, UnitPrice = 50m },
            new LineItem { Description = "Gadget", Quantity = 1, UnitPrice = 100m }
        }
    };

    // Act
    var total = invoice.CalculateTotal();

    // Assert
    Assert.AreEqual(200m, total); // 2 * 50 + 1 * 100 = 200
}

[Test]
public void CalculateTax_WithTaxRate_ReturnsCorrectAmount()
{
    // Arrange
    var invoice = new Invoice
    {
        LineItems = new()
        {
            new LineItem { Quantity = 1, UnitPrice = 100m }
        }
    };

    // Act
    var tax = invoice.CalculateTax(0.1m);

    // Assert
    Assert.AreEqual(10m, tax); // 100 * 0.1 = 10
}

These tests are faster, more readable, and more maintainable than tests that mock everything. They also catch real bugs in your domain logic. Save mocking for the boundaries where it matters.

Common mistakes

Over-specifying argument verification. This test is fragile:

csharp
// DON'T: Too specific on the email body
await emailSender.Received(1).SendAsync(
    "user@example.com",
    "Receipt",
    "Thank you for your payment of $99.99. ID: inv_12345abc"
);

If the email body changes slightly (formatting, punctuation), the test breaks even though the behavior is correct. Use Arg.Any<string>() or Arg.Is<string>(body => body.Contains("Thank you")) instead:

csharp
// DO: Verify what matters
await emailSender.Received(1).SendAsync(
    "user@example.com",
    "Receipt",
    Arg.Is<string>(body => body.Contains("Thank you") && body.Contains("$99.99"))
);

Mocking the class under test. This defeats the purpose:

csharp
// DON'T: Never mock the service you're testing
var service = Substitute.For<BillingService>();

You're not testing anything. Create real instances of the class under test; mock its dependencies.

Forgetting to configure async returns. NSubstitute requires explicit configuration for async methods:

csharp
// DON'T: This doesn't work for async methods
var repo = Substitute.For<IUserRepository>();
repo.GetByIdAsync("123").Returns(new User { Id = "123" });

// DO: Wrap in Task.FromResult
repo.GetByIdAsync("123").Returns(Task.FromResult(new User { Id = "123" }));

Key takeaways

  • Create substitutes with one line: Substitute.For<IInterface>(). No boilerplate, no ceremony.
  • Use Returns() to configure stub behavior and Received() to verify method calls after the fact.
  • Mock at boundaries: external services (email, payment, APIs), non-deterministic behavior (clock), and data persistence. These are the places where mocking prevents side effects and improves test reliability.
  • Never mock what you own and can run cheaply. Test your domain models and business logic directly with real instances.
  • Keep argument verification loose where specifics don't matter. Tight over-specification makes tests brittle and maintenance-heavy.
  • NSubstitute's readability pays dividends in large test suites. Your teammates will understand the intent without hunting through documentation.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles