Tdd Fundamentals · Common pitfalls
2 min readCommon pitfalls
A: The Liar (tests with no meaningful assertions), brittle tests that verify implementation details, the Giant (one test covering too many behaviors), excessive setup indicating too many dependencies, shared mutable state causing order-dependent failures, and copy-paste tests that become a maintenance burden. The cure is testing behavior over implementation, keeping tests focused, and using builders/fixtures to reduce duplication.
A: A brittle test breaks when you refactor production code without changing behavior. The most common cause is testing implementation details rather than observable outcomes:
// BAD: testing exact method call sequence — any refactoring breaks these tests
mockRepo.Verify(r => r.OpenConnection(), Times.Once);
mockRepo.Verify(r => r.BeginTransaction(), Times.Once);
mockRepo.Verify(r => r.SaveAsync(order), Times.Once);
mockRepo.Verify(r => r.CommitTransaction(), Times.Once);
mockRepo.Verify(r => r.CloseConnection(), Times.Once);
// BETTER: test the observable outcome
var savedOrder = await repo.GetByIdAsync(order.Id);
savedOrder.ShouldNotBeNull();
savedOrder.Status.ShouldBe(OrderStatus.Confirmed);A:
// BAD: asserting on internal data structure via reflection
Assert.Equal(3, sut._internalCache.Count); // accessing private field
// BETTER: assert on public behavior
var result = sut.GetAllCachedItems();
result.Count.ShouldBe(3);
// BAD: verifying that a specific private method was called
// (this test will break if you rename or restructure the private method)
// BETTER: verify the external effect of calling the public methodA: An inverted test pyramid with many end-to-end tests and few unit tests. Results in slow feedback, flaky CI, and hard-to-diagnose failures:
Correct (pyramid): Anti-pattern (ice cream cone):
/\ E2E __________ E2E
/ \ |__________|
/ \ Integration | | Integration
/______\ | |
/ \ Unit |__________| Unit
/____________\ | |A: A test that passes but does not actually verify behavior. The assertions are missing or too weak:
// BAD: The Liar — test passes but verifies nothing
[Fact]
public async Task ProcessOrder_DoesNotThrow()
{
var sut = new OrderProcessor(new Mock<IOrderRepository>().Object);
await sut.ProcessAsync(new Order("o1", 100m));
// No assertions! This always passes even if the code is wrong.
}
// GOOD: actually verify the outcome
[Fact]
public async Task ProcessOrder_ValidOrder_PersistsWithConfirmedStatus()
{
var repo = new FakeOrderRepository();
var sut = new OrderProcessor(repo);
await sut.ProcessAsync(new Order("o1", 100m));
var saved = await repo.GetByIdAsync("o1");
saved.ShouldNotBeNull();
saved.Status.ShouldBe(OrderStatus.Confirmed);
}A: Verifying behavior you configured on the mock rather than on the system under test:
// BAD: you are testing Moq, not your code
var mock = new Mock<ICalculator>();
mock.Setup(c => c.Add(2, 3)).Returns(5);
Assert.Equal(5, mock.Object.Add(2, 3)); // This tests Moq itself
// GOOD: test your code that USES the calculator
var calc = new Mock<ICalculator>();
calc.Setup(c => c.Add(It.IsAny<int>(), It.IsAny<int>())).Returns(10);
var sut = new InvoiceService(calc.Object);
var invoice = sut.CalculateTotal(items);
invoice.Total.ShouldBe(10);- Tests that hit real databases, networks, or file systems without justification — use fakes or Testcontainers.
- Tests that use
Thread.SleeporTask.Delayfor synchronization — useTaskCompletionSourceorSemaphoreSlim. - Tests that boot the entire application when a unit test would suffice — push most coverage to unit tests.
- Tests that create expensive resources per test instead of sharing via fixtures — use
IClassFixture<T>.
A:
A: Fifty lines of Arrange for one line of Act signals the system under test has too many dependencies. It is a design smell — consider breaking the class into smaller, focused components. In the meantime, use builders, AutoFixture, or shared factory methods to reduce boilerplate.
A: Delete tests that test deleted features, test implementation details that change with every refactor, duplicate other tests without adding coverage, test third-party library behavior (that is their responsibility), or are permanently flaky despite attempts to fix them. Dead tests erode trust in the suite and slow CI. Regularly prune tests during refactoring sessions.