Publishing Bi-Weekly Β· ASP.NET Core Β· Design Patterns Β· Architecture Β· 20 yrs C#/.NET Β· cleancsharp.com
Testing · Best Practices

πŸ§ͺΒ Unit Testing with NSubstitute β€” Tests That Survive Refactoring

You learned unit testing from a tutorial. The tutorial showed you how to mock. So you mock everything. Your tests pass. Coverage is 85%. Then you refactor a helper method and 47 tests explode. The code works. The tests don't know the difference.

When your tests know too much about implementation


A film set hires a stunt double for the dangerous scenes. The car chase. The three-story fall. The shot where the building goes up in flames. The double stands in exactly where the work could hurt someone, and the camera never knows the difference.

Nobody hires a stunt double to play the coffee cup on the table. The coffee cup isn't dangerous. It's a prop. You use the real one.

Your test suite hires stunt doubles for everything β€” including the coffee cups.

You learned unit testing from tutorials. The tutorials showed you how to mock dependencies. So you mock everything. Services, repositories, DTOs, value objects, even strings sometimes. Your tests pass. Code coverage hits 85%. Code review approves it.

Then you refactor. Extract a helper method. Rename a private field. Change how a loop is structured. You didn't change behavior β€” you just moved code around to make it cleaner.

47 test failures.

Every test needs updating. Not because the code is broken β€” it works perfectly. But because your tests knew too much about the implementation. They were testing how you did something, not what you accomplished.

Here's what actually happens when tests know too much:

  • Monday, 9:15 AM β€” Sprint planning, team commits to refactoring ClaimProcessingService
  • Monday, 10:30 AM β€” Developer extracts validation logic into helper methods (behavior unchanged)
  • Monday, 10:45 AM β€” Runs tests: 47 failures
  • Monday, 11:00 AM β€” Realizes tests are mocking internal helper methods
  • Monday, 11:30 AM β€” Starts updating tests one by one
  • Tuesday, 2:00 PM β€” Still updating tests
  • Wednesday, 10:00 AM β€” Finally all tests pass
  • Wednesday, 10:30 AM β€” PR review: "Why did you change 200 lines of test code for a 50-line refactor?"
  • Wednesday, 3:00 PM β€” Team discusses whether refactoring is worth it if tests break
  • Thursday β€” Technical debt grows because refactoring is too expensive

Your tests were "correct." The problem was they knew too much about implementation details.


🎯 The Shift

From: "Tests that know how you implemented something"
To: "Tests that verify what you accomplished"

I've inherited test suites at companies pushing millions of healthcare claims through production β€” and the pattern is always the same:

  • Test suite takes 20+ minutes to run
  • Developers stop running tests locally
  • Tests break on every refactor
  • Team loses trust in tests ("they're always red anyway")
  • Coverage stays high while bugs ship
  • Nobody knows which tests actually matter

Your tests don't control what you can change. But you control what your tests verify.

Let's break down what goes wrong, why tests become brittle, and how to write tests that survive refactoring.


❌ Pattern #1: Mocking Everything

The common approach:
"The tutorial said to mock dependencies. These are dependencies. So I mock them."

What most developers write:

[Fact]
public async Task GetClaim_ReturnsClaimDto()
{
    // Arrange
    var mockRepository = Substitute.For<IClaimRepository>();
    var mockMapper = Substitute.For<IMapper>();
    var mockLogger = Substitute.For<ILogger<ClaimService>>();
    var mockValidator = Substitute.For<IValidator>();
    var mockDto = Substitute.For<ClaimDto>();  // ❌ Mocking a DTO β€” non-virtual members aren't intercepted, so Returns() throws
    var mockClaim = Substitute.For<Claim>();   // ❌ Mocking an entity β€” same trap

    mockRepository.GetClaimAsync("CLM-123", Arg.Any<CancellationToken>())
        .Returns(mockClaim);
    mockMapper.Map<ClaimDto>(mockClaim).Returns(mockDto);

    var service = new ClaimService(
        mockRepository,
        mockMapper,
        mockLogger,
        mockValidator);

    // Act
    var result = await service.GetClaimAsync("CLM-123", CancellationToken.None);

    // Assert
    Assert.Equal(mockDto, result.Value);
    await mockRepository.Received(1).GetClaimAsync("CLM-123", Arg.Any<CancellationToken>());
    mockMapper.Received(1).Map<ClaimDto>(mockClaim);
}

Looks thorough, right? You're mocking all the dependencies. You're verifying all the interactions with Received(). Code coverage shows this line is tested.

What this test actually verifies:

"When you call IClaimRepository and IMapper in this exact order with these exact parameters, you get back what you mocked."

What this test doesn't verify:

  • Does the claim data make sense?
  • Is the ClaimDto correctly populated?
  • Would this work with real data?
  • What happens if the claim is cancelled?
  • What happens if the claim doesn't exist?

Why it's brittle:

  • Change how you call IMapper? Test breaks.
  • Extract a helper method? Test breaks.
  • Add logging? Test breaks.
  • Rename a parameter? Test breaks.

The actual behavior works perfectly, but the test doesn't know the difference.

The fix: Mock at boundaries, use real objects for data

[Fact]
public async Task GetClaim_WhenClaimExists_ReturnsClaimWithCorrectData()
{
    // Arrange - only mock the boundary (repository)
    var repository = Substitute.For<IClaimRepository>();
    var logger = Substitute.For<ILogger<ClaimService>>();

    // Use real objects for data
    var claim = new Claim
    {
        Id = "CLM-123",
        PatientId = "PT-456",
        ProviderId = "PRV-789",
        Amount = 150.00m,
        Status = ClaimStatus.Submitted,
        ServiceDate = new DateTime(2024, 3, 15)
    };

    repository.GetClaimAsync("CLM-123", Arg.Any<CancellationToken>())
        .Returns(claim);

    // Same constructor, same service β€” the mapper and validator are pure logic,
    // so they get the real thing
    var service = new ClaimService(
        repository, new ClaimMapper(), logger, new ClaimValidator());

    // Act
    var result = await service.GetClaimAsync("CLM-123", CancellationToken.None);

    // Assert - verify the behavior, not the implementation
    Assert.True(result.IsSuccess);
    Assert.Equal("CLM-123", result.Value.Id);
    Assert.Equal("PT-456", result.Value.PatientId);
    Assert.Equal(150.00m, result.Value.Amount);
    Assert.Equal("Submitted", result.Value.Status); // ClaimMapper stringifies the enum
}

What changed:

  • Only mock the boundaries (IClaimRepository, ILogger)
  • Use real Claim objects (they're just data)
  • Use real DTOs (they're just data)
  • Use the real mapper and validator β€” pure logic, no boundaries. If the mapping is wrong, this test catches it. The mocked-mapper version never could.
  • Verify actual data values, not that methods were called

Now you can:

  • Refactor how you map Claim to ClaimDto β†’ test still passes
  • Extract helper methods β†’ test still passes
  • Change internal implementation β†’ test still passes
  • Add logging β†’ test still passes

The test verifies behavior (claim data is returned correctly), not implementation (IMapper.Map() was called).


πŸ§ͺ Pattern #2: Testing Implementation Details

The common approach:
"I need to test this helper method. I'll make it public so I can test it."

What most developers write:

public class PrescriptionService
{
    private readonly IPrescriptionRepository _repository;

    public PrescriptionService(IPrescriptionRepository repository)
    {
        _repository = repository;
    }

    // Made public to test it ❌
    public bool IsExpired(DateTime expirationDate)
    {
        return expirationDate < DateTime.UtcNow;
    }

    // Made public to test it ❌
    public int CalculateRefillsRemaining(int totalRefills, int usedRefills)
    {
        return totalRefills - usedRefills;
    }

    public async Task<Result<Prescription>> GetPrescriptionAsync(
        int prescriptionId, CancellationToken cancellationToken)
    {
        var prescription = await _repository.FindAsync(prescriptionId, cancellationToken);

        if (prescription == null)
            return Result<Prescription>.Failure($"Prescription {prescriptionId} not found");

        if (IsExpired(prescription.ExpirationDate))
            return Result<Prescription>.Failure($"Prescription {prescriptionId} expired");

        var refillsRemaining = CalculateRefillsRemaining(
            prescription.TotalRefills,
            prescription.UsedRefills);

        if (refillsRemaining <= 0)
            return Result<Prescription>.Failure("No refills remaining");

        return Result<Prescription>.Success(prescription);
    }
}

// Tests
[Fact]
public void IsExpired_WhenDateInPast_ReturnsTrue()
{
    var service = new PrescriptionService(null!);
    var result = service.IsExpired(DateTime.UtcNow.AddDays(-1));
    Assert.True(result);
}

[Fact]
public void CalculateRefillsRemaining_Returns_CorrectValue()
{
    var service = new PrescriptionService(null!);
    var result = service.CalculateRefillsRemaining(5, 2);
    Assert.Equal(3, result);
}

What's wrong here:

  1. Helper methods made public just to test them
  2. Tests coupled to internal implementation
  3. Behavior (GetPrescriptionAsync) tested through implementation details
  4. If you refactor how IsExpired() works, tests break

The fix: Test behavior through the public API

public class PrescriptionService
{
    private readonly IPrescriptionRepository _repository;

    public PrescriptionService(IPrescriptionRepository repository)
    {
        _repository = repository;
    }

    // Private - implementation detail
    private bool IsExpired(DateTime expirationDate)
    {
        return expirationDate < DateTime.UtcNow;
    }

    // Private - implementation detail
    private int CalculateRefillsRemaining(int totalRefills, int usedRefills)
    {
        return totalRefills - usedRefills;
    }

    public async Task<Result<Prescription>> GetPrescriptionAsync(
        int prescriptionId, CancellationToken cancellationToken)
    {
        var prescription = await _repository.FindAsync(prescriptionId, cancellationToken);

        if (prescription == null)
            return Result<Prescription>.Failure($"Prescription {prescriptionId} not found");

        if (IsExpired(prescription.ExpirationDate))
            return Result<Prescription>.Failure($"Prescription {prescriptionId} expired");

        var refillsRemaining = CalculateRefillsRemaining(
            prescription.TotalRefills,
            prescription.UsedRefills);

        if (refillsRemaining <= 0)
            return Result<Prescription>.Failure("No refills remaining");

        return Result<Prescription>.Success(prescription);
    }
}

// Tests - verify behavior, not implementation
[Fact]
public async Task GetPrescription_WhenExpired_ReturnsFailure()
{
    // Arrange
    var repository = Substitute.For<IPrescriptionRepository>();
    var expiredPrescription = new Prescription
    {
        Id = 123,
        ExpirationDate = DateTime.UtcNow.AddDays(-1),  // Expired yesterday
        TotalRefills = 5,
        UsedRefills = 2
    };

    repository.FindAsync(123, Arg.Any<CancellationToken>())
        .Returns(expiredPrescription);
    var service = new PrescriptionService(repository);

    // Act
    var result = await service.GetPrescriptionAsync(123, CancellationToken.None);

    // Assert
    Assert.False(result.IsSuccess);
    Assert.Contains("expired", result.Error, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task GetPrescription_WhenNoRefillsRemaining_ReturnsFailure()
{
    // Arrange
    var repository = Substitute.For<IPrescriptionRepository>();
    var prescription = new Prescription
    {
        Id = 123,
        ExpirationDate = DateTime.UtcNow.AddDays(30),  // Valid
        TotalRefills = 5,
        UsedRefills = 5  // All refills used
    };

    repository.FindAsync(123, Arg.Any<CancellationToken>())
        .Returns(prescription);
    var service = new PrescriptionService(repository);

    // Act
    var result = await service.GetPrescriptionAsync(123, CancellationToken.None);

    // Assert
    Assert.False(result.IsSuccess);
    Assert.Contains("No refills", result.Error);
}

[Fact]
public async Task GetPrescription_WhenValid_ReturnsSuccess()
{
    // Arrange
    var repository = Substitute.For<IPrescriptionRepository>();
    var prescription = new Prescription
    {
        Id = 123,
        ExpirationDate = DateTime.UtcNow.AddDays(30),  // Valid
        TotalRefills = 5,
        UsedRefills = 2  // Refills remaining
    };

    repository.FindAsync(123, Arg.Any<CancellationToken>())
        .Returns(prescription);
    var service = new PrescriptionService(repository);

    // Act
    var result = await service.GetPrescriptionAsync(123, CancellationToken.None);

    // Assert
    Assert.True(result.IsSuccess);
    Assert.Equal(123, result.Value.Id);
}

What changed:

  • Helper methods are private (implementation details)
  • Tests verify behavior through the public API (GetPrescriptionAsync)
  • Each test covers a specific scenario (expired, no refills, valid)
  • Helper method logic is tested implicitly through behavior

One caveat: IsExpired reads DateTime.UtcNow directly. In real code, inject TimeProvider (.NET 8+) and substitute it in tests β€” the clock is a boundary too.

Now you can:

  • Refactor how IsExpired() works β†’ tests still pass
  • Extract different helper methods β†’ tests still pass
  • Combine expiration and refills check β†’ tests still pass
  • Change the calculation logic β†’ tests might fail (good! behavior changed)

The tests verify behavior (prescription validation works), not implementation (how you check expiration).


πŸ—οΈ Pattern #3: Poor Test Structure

The common approach:
"I'll just write the test and see if it passes."

What most developers write:

[Fact]
public async Task Test1()
{
    var repo = Substitute.For<IClaimRepository>();
    repo.GetClaimAsync("CLM-123", Arg.Any<CancellationToken>())
        .Returns(new Claim { Id = "CLM-123", Amount = 100.00m });
    var svc = new ClaimService(repo, new ClaimMapper(), Substitute.For<ILogger<ClaimService>>(), new ClaimValidator());
    var result = await svc.GetClaimAsync("CLM-123", CancellationToken.None);
    Assert.True(result.IsSuccess);
    Assert.Equal(100.00m, result.Value.Amount);
}

What's wrong:

  • Test name is meaningless (Test1)
  • No clear structure (where does Arrange end and Act begin?)
  • Multiple assertions with no explanation
  • No context for why this test exists

Three months later, this test fails. You have no idea what it's testing or why it matters.

The fix: Use AAA pattern and descriptive names

[Fact]
public async Task GetClaim_WhenClaimExists_ReturnsSuccessWithAmount()
{
    // Arrange - set up the scenario
    var repository = Substitute.For<IClaimRepository>();
    var existingClaim = new Claim
    {
        Id = "CLM-123",
        Amount = 150.00m,
        Status = ClaimStatus.Submitted
    };

    repository.GetClaimAsync("CLM-123", Arg.Any<CancellationToken>())
        .Returns(existingClaim);
    var service = new ClaimService(
        repository, new ClaimMapper(), Substitute.For<ILogger<ClaimService>>(), new ClaimValidator());

    // Act - perform the action being tested
    var result = await service.GetClaimAsync("CLM-123", CancellationToken.None);

    // Assert - verify the expected outcome
    Assert.True(result.IsSuccess);
    Assert.Equal(150.00m, result.Value.Amount);
}

[Fact]
public async Task GetClaim_WhenClaimNotFound_ReturnsFailureWithMessage()
{
    // Arrange
    var repository = Substitute.For<IClaimRepository>();
    repository.GetClaimAsync("CLM-999", Arg.Any<CancellationToken>())
        .Returns((Claim?)null);
    var service = new ClaimService(
        repository, new ClaimMapper(), Substitute.For<ILogger<ClaimService>>(), new ClaimValidator());

    // Act
    var result = await service.GetClaimAsync("CLM-999", CancellationToken.None);

    // Assert
    Assert.False(result.IsSuccess);
    Assert.Contains("CLM-999", result.Error);
    Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase);
}

Test naming pattern:

MethodName_Scenario_ExpectedBehavior

Examples:

  • ProcessClaim_WhenApproved_SendsEmailNotification
  • ValidatePrescription_WhenExpired_ReturnsValidationError
  • CalculateRefund_WithPartialRefund_ReturnsCorrectAmount

The test is self-documenting. Six months later, when it fails, you know exactly what scenario broke.


βš–οΈ Pattern #4: What to Mock (And What Not To)

The rule: Mock dependencies you don't control. Use real objects for everything else.

βœ… DO Mock:

External dependencies β€” things that cross system boundaries:

var repository = Substitute.For<IClaimRepository>();  // βœ… Database
var emailService = Substitute.For<IEmailService>();   // βœ… Email sending
var paymentApi = Substitute.For<IPaymentClient>();    // βœ… External API
var logger = Substitute.For<ILogger<ClaimService>>(); // βœ… Infrastructure

Why: These cross system boundaries. Real implementations would hit databases, send emails, call APIs. Tests would be slow and fragile.

ILogger is the borderline one, and by the rule at the bottom of this section it doesn't qualify β€” it's fast, deterministic, and depends on nothing. Reach for NullLogger<T>.Instance when the logger is just a constructor argument you have to satisfy. Substitute it only when the logging is the behavior under test β€” "a failed claim writes a warning" is a real requirement, and Received() is how you check it.

❌ DON'T Mock:

Data objects β€” they're just data:

// Use real objects for data
var claim = new Claim { Id = "CLM-123", Amount = 100 };        // βœ… Real object
var dto = new ClaimDto { Id = "CLM-123", Amount = 100 };       // βœ… Real object
var request = new ProcessClaimRequest { ClaimId = "CLM-123" }; // βœ… Real object

// DON'T do this
var claim = Substitute.For<Claim>();     // ❌ Mocking data
var dto = Substitute.For<ClaimDto>();    // ❌ Mocking data

Why: These are just data. Using real objects verifies that your code works with actual data structures.

It's not just a style call β€” the tool itself fights you. Substitute.For<T>() builds a proxy by subclassing at runtime. Interfaces and delegates? Fully substitutable. Classes? NSubstitute can proxy a non-sealed class, but it only intercepts virtual members. Your typical DTO is auto-properties all the way down β€” none of them virtual. Call one on that Substitute.For<Claim>() and the real property runs; NSubstitute never records it, so the Returns() that follows has no call to attach to and throws CouldNotSetReturnDueToNoLastCallException. That's the good outcome β€” you find out immediately. The bad one is when an earlier intercepted call is still in the buffer and Returns() silently latches onto that instead. The test goes green. It's testing nothing. Sealed classes don't even get that far β€” they fail at creation. Positional records fail one step later: hand Substitute.For<T>() their constructor args and you'll get an object back, but the generated properties still aren't virtual, so nothing you set up sticks. Skip the minefield: construct the real thing. And add NSubstitute.Analyzers.CSharp to your test project β€” it flags non-virtual setups at compile time, before they lie to you.

Value objects β€” they're deterministic and fast:

var amount = new Money(150.00m, Currency.USD);  // βœ… Real object
var dateRange = new DateRange(start, end);      // βœ… Real object
var address = new Address("123 Main St", ...);  // βœ… Real object

Services that don't cross boundaries β€” use the real thing:

var validator = new ClaimValidator();     // βœ… Real validator (no dependencies)
var calculator = new RefundCalculator();  // βœ… Real calculator (pure logic)
var mapper = new ClaimMapper();           // βœ… Real mapper (no dependencies)

If it's fast, deterministic, and has no external dependencies β€” use the real thing.


🎨 Complete Example: Testing a WebAPI Controller

Let's test a ClaimsController using all the patterns:

// Controller being tested
[ApiController]
[Route("api/v1/claims")]
public class ClaimsController : ControllerBase
{
    private readonly IClaimService _claimService;

    public ClaimsController(IClaimService claimService)
    {
        _claimService = claimService;
    }

    [HttpGet("{claimId}")]
    public async Task<IActionResult> GetClaim(
        string claimId, CancellationToken cancellationToken)
    {
        var result = await _claimService.GetClaimAsync(claimId, cancellationToken);

        if (!result.IsSuccess)
            return NotFound(new ProblemDetails
            {
                Title = "Claim not found",
                Detail = result.Error
            });

        return Ok(result.Value);
    }

    [HttpPost]
    public async Task<IActionResult> ProcessClaim(
        ProcessClaimRequest request, CancellationToken cancellationToken)
    {
        var result = await _claimService.ProcessClaimAsync(request, cancellationToken);

        if (!result.IsSuccess)
            return BadRequest(new ProblemDetails
            {
                Title = "Claim processing failed",
                Detail = result.Error
            });

        return CreatedAtAction(
            nameof(GetClaim),
            new { claimId = result.Value.Id },
            result.Value);
    }
}

// Tests - following all the patterns
public class ClaimsControllerTests
{
    [Fact]
    public async Task GetClaim_WhenClaimExists_ReturnsOkWithClaimData()
    {
        // Arrange - only mock the service boundary
        var claimService = Substitute.For<IClaimService>();

        // Use real objects for data
        var claimDto = new ClaimDto
        {
            Id = "CLM-123",
            PatientId = "PT-456",
            Amount = 150.00m,
            Status = "Submitted"
        };

        claimService.GetClaimAsync("CLM-123", Arg.Any<CancellationToken>())
            .Returns(Result<ClaimDto>.Success(claimDto));

        var controller = new ClaimsController(claimService);

        // Act
        var result = await controller.GetClaim("CLM-123", CancellationToken.None);

        // Assert - verify behavior, not implementation
        var okResult = Assert.IsType<OkObjectResult>(result);
        var returnedClaim = Assert.IsType<ClaimDto>(okResult.Value);
        Assert.Equal("CLM-123", returnedClaim.Id);
        Assert.Equal(150.00m, returnedClaim.Amount);
    }

    [Fact]
    public async Task GetClaim_WhenClaimNotFound_ReturnsNotFoundWithProblemDetails()
    {
        // Arrange
        var claimService = Substitute.For<IClaimService>();
        claimService.GetClaimAsync("CLM-999", Arg.Any<CancellationToken>())
            .Returns(Result<ClaimDto>.Failure("Claim CLM-999 not found"));

        var controller = new ClaimsController(claimService);

        // Act
        var result = await controller.GetClaim("CLM-999", CancellationToken.None);

        // Assert
        var notFoundResult = Assert.IsType<NotFoundObjectResult>(result);
        var problemDetails = Assert.IsType<ProblemDetails>(notFoundResult.Value);
        Assert.Equal("Claim not found", problemDetails.Title);
        Assert.Contains("CLM-999", problemDetails.Detail);
    }

    [Fact]
    public async Task ProcessClaim_WhenValid_ReturnsCreatedWithLocation()
    {
        // Arrange
        var claimService = Substitute.For<IClaimService>();
        var request = new ProcessClaimRequest
        {
            PatientId = "PT-456",
            ProviderId = "PRV-789",
            Amount = 250.00m
        };

        var processedClaim = new ClaimDto
        {
            Id = "CLM-NEW",
            PatientId = "PT-456",
            Amount = 250.00m,
            Status = "Pending"
        };

        claimService.ProcessClaimAsync(request, Arg.Any<CancellationToken>())
            .Returns(Result<ClaimDto>.Success(processedClaim));

        var controller = new ClaimsController(claimService);

        // Act
        var result = await controller.ProcessClaim(request, CancellationToken.None);

        // Assert
        var createdResult = Assert.IsType<CreatedAtActionResult>(result);
        Assert.Equal(nameof(ClaimsController.GetClaim), createdResult.ActionName);
        Assert.Equal("CLM-NEW", createdResult.RouteValues!["claimId"]);

        var returnedClaim = Assert.IsType<ClaimDto>(createdResult.Value);
        Assert.Equal("Pending", returnedClaim.Status);
    }

    [Fact]
    public async Task ProcessClaim_WhenValidationFails_ReturnsBadRequestWithProblemDetails()
    {
        // Arrange
        var claimService = Substitute.For<IClaimService>();
        var invalidRequest = new ProcessClaimRequest
        {
            PatientId = "", // Invalid - empty patient ID
            Amount = -100   // Invalid - negative amount
        };

        claimService.ProcessClaimAsync(invalidRequest, Arg.Any<CancellationToken>())
            .Returns(Result<ClaimDto>.Failure("Patient ID is required. Amount must be positive."));

        var controller = new ClaimsController(claimService);

        // Act
        var result = await controller.ProcessClaim(invalidRequest, CancellationToken.None);

        // Assert
        var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
        var problemDetails = Assert.IsType<ProblemDetails>(badRequestResult.Value);
        Assert.Contains("Patient ID", problemDetails.Detail);
        Assert.Contains("Amount", problemDetails.Detail);
    }
}

What makes these tests good:

  1. Only mock IClaimService (the boundary) β€” not DTOs, not requests, not Result<T>
  2. Test behavior β€” verify HTTP status codes and response content, not internal calls
  3. Clear AAA structure β€” easy to read, easy to understand
  4. Descriptive names β€” GetClaim_WhenClaimNotFound_ReturnsNotFoundWithProblemDetails tells you exactly what broke
  5. Use real objects for data β€” real ClaimDto, real ProcessClaimRequest
  6. Assert on behavior β€” not on whether Received() was called

Now you can:

  • Refactor how the controller calls IClaimService β†’ tests still pass
  • Change error message formatting β†’ tests still pass
  • Add logging β†’ tests still pass
  • Extract helper methods β†’ tests still pass

The tests verify controller behavior (HTTP responses), not implementation (method calls).


πŸ”§ NSubstitute Essentials

using NSubstitute;
using NSubstitute.ExceptionExtensions;  // for Throws / ThrowsAsync

Basic substitute creation:

// Create a substitute for an interface
var repository = Substitute.For<IClaimRepository>();

// Avoid substituting concrete classes β€” substitute interfaces instead.
// If you need to substitute a class, all intercepted methods must be virtual.

Setting up return values with .Returns():

// Return a value
repository.GetClaimAsync("CLM-123", Arg.Any<CancellationToken>())
    .Returns(new Claim { Id = "CLM-123" });

// Return different values on successive calls
repository.GetNextClaimAsync(Arg.Any<CancellationToken>())
    .Returns(claim1, claim2, claim3);

// Return based on argument
repository.GetClaimAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
    .Returns(callInfo => new Claim { Id = callInfo.Arg<string>() });

Verifying calls with Received() and DidNotReceive():

// Verify method was called
await repository.Received(1)
    .GetClaimAsync("CLM-123", Arg.Any<CancellationToken>());

// Verify method was NOT called
await repository.DidNotReceive()
    .DeleteClaimAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());

// Verify with argument matching
await emailService.Received(1).SendEmailAsync(
    Arg.Is<string>(email => email.Contains("@")),
    Arg.Any<string>(),
    Arg.Any<CancellationToken>());

Argument matching with Arg:

// Any value
repository.GetClaimAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());

// Specific value with condition
repository.GetClaimAsync(
    Arg.Is<string>(id => id.StartsWith("CLM-")),
    Arg.Any<CancellationToken>());

// Capture an argument for inspection. Inside Received() this replays
// calls that already happened, so it belongs in Assert. Used bare, it
// instead arranges a callback for FUTURE calls and must precede the Act.
// Either way it fires on every matching call, not just the first.
string? capturedId = null;
await repository.Received(1).GetClaimAsync(
    Arg.Do<string>(id => capturedId = id),
    Arg.Any<CancellationToken>());

Throwing exceptions with .ThrowsAsync():

// Throw on async method
repository.GetClaimAsync("CLM-ERROR", Arg.Any<CancellationToken>())
    .ThrowsAsync(new DatabaseException("Connection timeout"));

// Throw on any call
repository.SaveAsync(Arg.Any<Claim>(), Arg.Any<CancellationToken>())
    .ThrowsAsync(new Exception("Database unavailable"));

πŸŽ“ Why These Patterns Matter

Every brittle test suite I've been handed follows the same script:

Tests know implementation details β†’ Refactoring breaks tests β†’ Team stops refactoring β†’ Technical debt accumulates β†’ Velocity drops

Picture two teams working on claims processing systems. Same feature. Same deadline.

Team A writes tests that know too much:

  • Every helper method is mocked with Substitute.For<T>()
  • Tests verify method call order with Received()
  • Internal state is tested through public methods made public just for tests
  • 85% code coverage
  • Refactor a service: 47 tests break
  • Time spent fixing tests: 2 days
  • Team stops refactoring "because tests break"
  • Technical debt grows
  • Test suite takes 25 minutes to run
  • Developers stop running dotnet test locally

Team B writes tests that verify behavior:

  • Only boundaries are mocked (IRepository, IEmailService, IPaymentClient)
  • Tests verify what code accomplishes, not how
  • Internal implementation can change freely
  • 78% code coverage (but it's meaningful coverage)
  • Refactor a service: 0 tests break
  • Time spent fixing tests: 0 minutes
  • Team refactors confidently
  • Technical debt stays manageable
  • Test suite runs in 3 minutes
  • Developers run dotnet test on every change

Same codebase complexity. One team's tests enable change. The other team's tests prevent it.

Your testing strategy is the difference between code that improves over time and code that calcifies.


🧭 Key Takeaways

  • Mock at boundaries β€” IRepository, IEmailService, IPaymentClient. Use real objects for data.
  • Test behavior, not implementation β€” verify what code accomplishes, not how it's implemented
  • Don't test private methods β€” test behavior through the public API
  • Use AAA structure β€” Arrange, Act, Assert. Make tests readable.
  • Name tests descriptively β€” MethodName_Scenario_ExpectedBehavior
  • One assertion concept per test β€” multiple Assert calls are fine if they verify one concept
  • Tests should survive refactoring β€” if behavior didn't change, tests shouldn't break

πŸš€ Next Steps

Review your test suite:

  1. Are you mocking DTOs or value objects? Use real Claim, OrderDto, ProcessClaimRequest objects instead
  2. Are your tests breaking on refactors? You're testing implementation, not behavior
  3. Do you have public methods just for testing? Test through the public API
  4. Can you tell what a test verifies from its name? Use MethodName_Scenario_ExpectedBehavior
  5. Do your tests take 20+ minutes to run? You might be mocking too little (hitting real databases)

Start with your most-changed services (the ones you refactor often). Rewrite tests to verify behavior at boundaries. Watch how much easier refactoring becomes.

The test suites that enable change test behavior. The ones that prevent change test implementation.

Stunt doubles for the car chase. The real coffee cup on the table. Cast your tests the same way.


Related Posts:

In the next post: EF Core performance β€” the query patterns that quietly kill throughput, the N+1s you never see in dev, and how to find them before production traffic does.

// KEEP READING