Publishing Bi-Weekly ยท ASP.NET Core ยท Design Patterns ยท Architecture ยท 20 yrs C#/.NET ยท Jacksonville, FL ยท appliedcsharp.com

๐Ÿงช Unit Testing with NSubstitute โ€” Tests That Survive Refactoring

When your tests know too much about implementation


The Professor could build a radio from a coconut. But when Gilligan asked him to write tests for it, the Professor wrote tests that checked: - Which specific coconut was used - The exact order the wires were connected - How many times he twisted the dial - The precise angle of the antenna

The radio worked. But every time the Professor improved the design, all the tests broke. Not because the radio stopped working โ€” but because the tests knew too much about how it worked instead of what it did.

If only the Professor had tested that the radio could send and receive signals, instead of testing which coconut he used.

Your test suite makes the same mistake. 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: 52 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 debugged test suites at healthcare companies processing millions of claims, real estate platforms serving tens of millions of homes. The pattern repeats:

  • 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
    var mockClaim = Substitute.For<Claim>();   // โŒ Mocking an entity

    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);
    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);

    var service = new ClaimService(repository, logger);

    // 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);
}

What changed:

  • Only mock the boundary (IClaimRepository)
  • Use real Claim objects (they're just data)
  • Use real DTOs (they're just data)
  • No IMapper mock needed โ€” if you map correctly, this test verifies it
  • 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
{
    // 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 - 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

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 });
    var svc = new ClaimService(repo, Substitute.For<ILogger<ClaimService>>());
    var result = await svc.GetClaimAsync("CLM-123", CancellationToken.None);
    Assert.True(result.IsSuccess);
    Assert.Equal(100, 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, Substitute.For<ILogger<ClaimService>>());

    // 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, Substitute.For<ILogger<ClaimService>>());

    // 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.

โŒ 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.

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 argument for inspection
string capturedId = null;
repository.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

I've debugged test suites at healthcare companies processing millions of claims, real estate platforms serving tens of millions of homes. Every brittle test suite 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.

The Professor's radio tests checked which coconut he used. Don't let your tests make the same mistake.


Related Posts: - Building Professional WebAPI Controllers โ€” Controllers you'll be testing - Exception Handling That Survives Production โ€” Result<T> patterns used in tests - When the CRUD Hits the Fan โ€” Testing resilient patterns

In the next post: Async/await patterns that scale โ€” understanding the async state machine, avoiding thread starvation, and the patterns that actually improve performance under load.