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
ClaimDtocorrectly 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
Claimobjects (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
ClaimtoClaimDtoβ 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:
- Helper methods made
publicjust to test them - Tests coupled to internal implementation
- Behavior (
GetPrescriptionAsync) tested through implementation details - 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_SendsEmailNotificationValidatePrescription_WhenExpired_ReturnsValidationErrorCalculateRefund_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:
- Only mock
IClaimService(the boundary) β not DTOs, not requests, notResult<T> - Test behavior β verify HTTP status codes and response content, not internal calls
- Clear AAA structure β easy to read, easy to understand
- Descriptive names β
GetClaim_WhenClaimNotFound_ReturnsNotFoundWithProblemDetailstells you exactly what broke - Use real objects for data β real
ClaimDto, realProcessClaimRequest - 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
publicmethods 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 testlocally
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 teston 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
privatemethods β 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
Assertcalls 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:
- Are you mocking DTOs or value objects? Use real
Claim,OrderDto,ProcessClaimRequestobjects instead - Are your tests breaking on refactors? You're testing implementation, not behavior
- Do you have
publicmethods just for testing? Test through the public API - Can you tell what a test verifies from its name? Use
MethodName_Scenario_ExpectedBehavior - 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:
- 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: 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.