Learnixo
Back to blog
AI Systemsintermediate

Mocking with NSubstitute — Fakes, Stubs, and Spies

Use NSubstitute to isolate units under test: creating substitutes, configuring return values, verifying calls, argument matchers, and the mocking anti-patterns that make tests brittle.

Asma Hafeez KhanMay 16, 20264 min read
TestingNSubstituteMocking.NETUnit Testing
Share:𝕏

Why Mocking

Unit tests test one unit in isolation. A handler that depends on a repository, an email service, and a clock needs those dependencies replaced with controlled substitutes — so you test the handler's logic, not the infrastructure it calls.

Real dependencies in unit tests:
  ✗ Repository: requires a database — slow, stateful, brittle
  ✗ Email service: sends real emails — side effects
  ✗ Clock: depends on system time — non-deterministic

Substitutes:
  ✓ Repository.GetByIdAsync() returns what YOU specify
  ✓ Email service records that it was called — no emails sent
  ✓ Clock always returns the same DateTime — deterministic

Creating Substitutes

C#
// NuGet: NSubstitute
using NSubstitute;

// Create a substitute for any interface
var repo    = Substitute.For<IPatientRepository>();
var uow     = Substitute.For<IUnitOfWork>();
var email   = Substitute.For<IEmailService>();
var clock   = Substitute.For<IDateTimeProvider>();

// Inject into the class under test
var sut = new CreatePatientHandler(repo, uow, email, clock);

Configuring Return Values

C#
// Returns (stub) — configure what the mock returns
repo.GetByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
    .Returns(Task.FromResult<Patient?>(null));

// Returns a specific value for specific arguments
var patientId = Guid.NewGuid();
var patient   = Patient.Create("John Smith", new DateOnly(1985, 3, 15), "MRN-001").Value;
repo.GetByIdAsync(patientId, Arg.Any<CancellationToken>())
    .Returns(Task.FromResult<Patient?>(patient));

// Returns different values on consecutive calls
repo.ExistsByMRNAsync("MRN-001", Arg.Any<CancellationToken>())
    .Returns(false, true);  // first call: false, second call: true

// Returns via function (dynamic return)
repo.GetByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
    .Returns(callInfo =>
    {
        var id = callInfo.Arg<Guid>();
        return Task.FromResult<Patient?>(
            id == Guid.Empty ? null : patient);
    });

Argument Matchers

C#
// Arg.Any<T>() — match any argument of type T
repo.AddAsync(Arg.Any<Patient>(), Arg.Any<CancellationToken>())
    .Returns(Task.CompletedTask);

// Arg.Is<T>(predicate) — match specific arguments
repo.AddAsync(
    Arg.Is<Patient>(p => p.MRN == "MRN-001"),
    Arg.Any<CancellationToken>())
    .Returns(Task.CompletedTask);

// Specific value — exact match
repo.GetByIdAsync(patientId, CancellationToken.None)
    .Returns(Task.FromResult<Patient?>(patient));

Verifying Calls (Spy Behavior)

C#
// Verify a method was called
await repo.Received(1).AddAsync(Arg.Any<Patient>(), Arg.Any<CancellationToken>());

// Verify NOT called
await repo.DidNotReceive().AddAsync(Arg.Any<Patient>(), Arg.Any<CancellationToken>());

// Verify with argument inspection
await repo.Received(1).AddAsync(
    Arg.Is<Patient>(p => p.MRN == "MRN-001" && p.Name == "John Smith"),
    Arg.Any<CancellationToken>());

// Verify call count
await repo.Received(2).GetByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());

Complete Handler Test Example

C#
public class CreatePatientHandlerTests
{
    private readonly IPatientRepository _repo;
    private readonly IUnitOfWork        _uow;
    private readonly CreatePatientHandler _sut;

    public CreatePatientHandlerTests()
    {
        _repo = Substitute.For<IPatientRepository>();
        _uow  = Substitute.For<IUnitOfWork>();
        _sut  = new CreatePatientHandler(_repo, _uow);
    }

    [Fact]
    public async Task Handle_new_patient_should_persist_and_return_id()
    {
        // Arrange — configure substitute behavior
        _repo.ExistsByMRNAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
             .Returns(false);

        var cmd = new CreatePatientCommand("John Smith", new DateOnly(1985, 3, 15), "MRN-001");

        // Act
        var result = await _sut.Handle(cmd, CancellationToken.None);

        // Assert — result
        result.IsSuccess.Should().BeTrue();
        result.Value.Should().NotBe(Guid.Empty);

        // Assert — interactions
        await _repo.Received(1).AddAsync(
            Arg.Is<Patient>(p => p.MRN == "MRN-001"),
            Arg.Any<CancellationToken>());
        await _uow.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task Handle_duplicate_mrn_should_return_failure_without_saving()
    {
        // Arrange
        _repo.ExistsByMRNAsync("MRN-DUP", Arg.Any<CancellationToken>())
             .Returns(true);

        var cmd = new CreatePatientCommand("Jane Doe", new DateOnly(1990, 7, 22), "MRN-DUP");

        // Act
        var result = await _sut.Handle(cmd, CancellationToken.None);

        // Assert — failure
        result.IsFailure.Should().BeTrue();
        result.Error.Code.Should().Be("Patient.MRNAlreadyExists");

        // Assert — nothing was persisted
        await _repo.DidNotReceive().AddAsync(Arg.Any<Patient>(), Arg.Any<CancellationToken>());
        await _uow.DidNotReceive().SaveChangesAsync(Arg.Any<CancellationToken>());
    }
}

Simulating Exceptions

C#
// Configure a substitute to throw
repo.GetByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
    .ThrowsAsync(new TimeoutException("Database timeout"));

// Test that the handler handles the exception
[Fact]
public async Task Handle_db_timeout_should_return_failure()
{
    _repo.GetByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
         .ThrowsAsync(new TimeoutException());

    var result = await _sut.Handle(new GetPatientQuery(Guid.NewGuid()), CancellationToken.None);

    result.IsFailure.Should().BeTrue();
}

Capturing Arguments

C#
// Capture what was passed to the substitute
Patient? capturedPatient = null;
await _repo.AddAsync(Arg.Do<Patient>(p => capturedPatient = p), Arg.Any<CancellationToken>());

// Act
await _sut.Handle(cmd, CancellationToken.None);

// Assert on the captured value
capturedPatient.Should().NotBeNull();
capturedPatient!.Name.Should().Be("John Smith");
capturedPatient.IsActive.Should().BeTrue();

Mocking Anti-Patterns

Over-specifying:
  ✗ Verify exact argument values when any would do
  ✗ Verify order of calls when order does not matter
  Result: tests break on refactoring that does not change behavior

Under-specifying:
  ✗ Only verify happy path — never test that bad paths skip persistence
  ✗ Use Arg.Any() when a specific value matters to correctness

Mock everything:
  ✗ Mock value objects, domain entities, simple DTOs
  Only mock: infrastructure interfaces, external services, time providers
  Never mock: domain logic, value objects — test them directly

Red Flag / Green Answer

Red Flag: "Our unit test configures 15 substitute calls and verifies 10 of them. The test takes 5 minutes to write and breaks every refactoring."

Over-specified tests couple to implementation details, not behavior. If the handler calls _repo.GetByIdAsync twice instead of once but the result is the same, the test should not break.

Green Answer:

Verify only the side effects that matter: "was the patient saved?" and "was SaveChanges called?". Do not verify: "was GetById called before ExistsByMRN?" Implementation order is an internal detail.


Key Takeaway

NSubstitute creates substitutes for interfaces: .Returns() stubs return values, .Received() verifies calls, .DidNotReceive() verifies something was NOT called. Use Arg.Any<T>() for flexibility, Arg.Is<T>(predicate) when the argument matters. Verify behavior (was the patient persisted?), not implementation (was method X called before method Y?). Mock only infrastructure — test domain logic directly.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.