TDD Pitfalls ā Common Mistakes and How to Avoid Them
Avoid the most common TDD antipatterns in .NET: testing implementation details, brittle mocks, over-mocking, slow test suites, and the false confidence of low-value tests.
Why TDD Fails on Teams
TDD fails for one of these reasons:
1. Tests test implementation, not behaviour ā they break on every refactor
2. Over-mocking ā tests pass but the real system doesn't work
3. Test suite is too slow ā developers stop running it
4. Tests give false confidence ā high coverage, bugs still ship
5. Tests are written after the code ā defeats the design benefit of TDD
These are not arguments against TDD.
They are arguments against doing TDD badly.Pitfall 1 ā Testing Implementation Details
// BAD: test that verifies private method calls and internal state
[Fact]
public void ApprovePrescription_CallsValidateInrFirst()
{
// Testing that ValidateInr() is called ā not that the prescription is approved
var service = new PrescriptionApprovalService();
service.ApprovePrescription(prescriptionId, 2.5m, DateTime.UtcNow);
// This test breaks every time you rename ValidateInr() or extract it
// even if behaviour is identical
service.ValidateInrCallCount.Should().Be(1); // internal counter ā fragile
}
// GOOD: test observable behaviour, not internal mechanics
[Fact]
public void ApprovePrescription_WithValidInr_ReturnsSuccess()
{
var prescription = Prescription.CreateDraft(...);
var result = prescription.Approve(2.5m, DateTime.UtcNow, Guid.NewGuid());
// Tests what matters: the outcome, not how it was achieved
result.IsSuccess.Should().BeTrue();
prescription.Status.Should().Be(PrescriptionStatus.Approved);
}Pitfall 2 ā Over-Mocking
// BAD: mocking everything, including the system under test's own collaborators
// This test passes even if the real integration is completely broken
[Fact]
public void CreatePrescription_SavesToRepository()
{
var repo = Substitute.For<IPrescriptionRepository>();
var validator = Substitute.For<IPrescriptionValidator>();
var notifier = Substitute.For<INotificationService>();
var clock = Substitute.For<IClock>();
validator.Validate(Arg.Any<CreatePrescriptionCommand>())
.Returns(ValidationResult.Success);
var handler = new CreatePrescriptionHandler(repo, validator, notifier, clock);
await handler.Handle(command, CancellationToken.None);
await repo.Received(1).AddAsync(Arg.Any<Prescription>(), Arg.Any<CancellationToken>());
}
// What's wrong:
// ā Validator is mocked ā real validation never runs
// ā Clock is mocked ā real DateTime.UtcNow never involved
// ā The test proves the handler CALLS repo.AddAsync, not that a prescription is created correctly
// This test passes even if the handler creates a completely wrong Prescription object
// BETTER: use real collaborators where they're fast and deterministic
[Fact]
public async Task CreatePrescription_WithValidData_PrescriptionHasCorrectState()
{
var repo = new InMemoryPrescriptionRepository(); // real in-memory impl
var handler = new CreatePrescriptionHandler(repo);
var command = new CreatePrescriptionCommand(patientId, "Warfarin", 5m, "mg");
await handler.Handle(command, CancellationToken.None);
var saved = repo.All.Single();
saved.MedicationName.Value.Should().Be("Warfarin");
saved.Status.Should().Be(PrescriptionStatus.Draft);
}Pitfall 3 ā Slow Test Suite
Symptoms:
ā `dotnet test` takes more than 60 seconds for unit tests
ā Developers stop running tests between commits
ā "I'll run tests before pushing" ā often forgotten
Causes:
ā Unit tests hitting a real database (not InMemory or Testcontainers)
ā Thread.Sleep() or Task.Delay() in tests
ā Tests instantiating the full DI container when not needed
ā Tests loading appsettings.json and all configuration providers
Fixes:
ā Unit tests: no I/O, no network, no filesystem ā pure in-memory
ā Integration tests: Testcontainers (one container, shared across the test class)
ā Separate test projects: ClinicalSystem.UnitTests vs ClinicalSystem.IntegrationTests
ā CI runs unit tests on every commit, integration tests nightly or on PR
Target times:
ā Unit test suite (all domain logic): under 10 seconds
ā Integration tests (real DB): under 3 minutesPitfall 4 ā Testing the Framework, Not Your Code
// BAD: testing that ASP.NET Core routing works, or that EF Core can save an entity
[Fact]
public async Task AddPatient_StoresInDatabase()
{
var options = new DbContextOptionsBuilder<PatientsDbContext>()
.UseInMemoryDatabase("test")
.Build();
using var context = new PatientsDbContext(options);
var patient = new Patient { Id = Guid.NewGuid(), FullName = "Jane Doe" };
context.Patients.Add(patient);
await context.SaveChangesAsync();
// This tests EF Core, not your code
var found = await context.Patients.FindAsync(patient.Id);
found.Should().NotBeNull();
}
// You are testing EF Core's Add/Find ā that's Microsoft's job, not yours.
// Your code doesn't have any business logic in this test.
// BETTER: test your business logic
[Fact]
public void Patient_Create_WithValidMrn_Succeeds()
{
var result = Patient.Create(
PatientMrn.Of("MRN001"),
FullName.Of("Jane", "Doe"));
result.IsSuccess.Should().BeTrue();
result.Value.Mrn.Value.Should().Be("MRN001");
}Pitfall 5 ā Tests That Never Fail
// BAD: assertion that can never fail
[Fact]
public void GetPatients_ReturnsResult()
{
var service = new PatientService(Substitute.For<IPatientRepository>());
var result = service.GetAllAsync().Result;
// This assertion is always true ā even if result is an empty list
// or null, Should().NotBeNull() catches nothing meaningful
result.Should().NotBeNull();
}
// A test that can never fail provides false confidence.
// It inflates coverage metrics while catching no real bugs.
// BETTER: test the specific outcome
[Fact]
public async Task GetPatients_WithActivePatients_ReturnsOnlyActiveOnes()
{
var repo = Substitute.For<IPatientRepository>();
repo.GetAllAsync(Arg.Any<CancellationToken>())
.Returns(new[]
{
Patient.Create(PatientMrn.Of("MRN001"), FullName.Of("Jane", "Doe")).Value,
Patient.Create(PatientMrn.Of("MRN002"), FullName.Of("John", "Smith")).Value
});
var service = new PatientService(repo);
var result = await service.GetActiveAsync(CancellationToken.None);
result.Should().HaveCount(2);
result.All(p => p.IsActive).Should().BeTrue();
}TDD Pitfall Quick Reference
Pitfall ā Fix
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Testing private methods ā Test public behaviour only
Mocking value objects ā Use real value objects (they're fast)
One giant Arrange block ā Use Builder pattern or factory methods
Test names: "Test1", "Method_Works" ā Describe the scenario and expected outcome
Catching and swallowing exceptions ā Let exceptions propagate; assert on thrown
Assert first (result not null) only ā Assert the specific value or state
No test isolation (shared static state)ā Each test creates its own instances
Thread.Sleep in tests ā Use virtual clock (IClock) or ManualResetEventProduction issue I've seen: A team reported 87% code coverage and shipped a Warfarin dosage calculation bug that caused 14 patients to receive incorrect doses for 3 days. Investigation found that the dosage calculation method was "covered" by a test that called it with a valid input and asserted only that the result was not null. The test was counting code execution, not verifying output. The boundary case (dose calculation when INR was exactly 2.0 on the threshold) was never tested. Coverage percentage means nothing without assertions that would fail when the output is wrong.
Key Takeaway
TDD pitfalls undermine the value of the practice without making it obviously broken. Test observable behaviour, not internal method calls ā tests that break on renaming a private method are testing the wrong thing. Mock only true dependencies (databases, network, time) ā not domain logic or value objects. Keep unit tests under 10 seconds total. Write assertions that can fail: a test that can never go red provides no protection. High coverage with weak assertions is worse than moderate coverage with strong ones.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.