TDD & Test Doubles
Key Points
- TDD = Red → Green → Refactor. Write a failing test, write minimum code to pass, refactor while green. Discipline matters more than speed.
- Two schools: Classic (Detroit) = state-based, real collaborators where possible. London = interaction-based, mock everything beyond the unit. Both have valid uses.
- Test-double taxonomy (Gerard Meszaros, xUnit Test Patterns): dummy / stub / fake / spy / mock. "Mock" colloquially covers all five — be precise in design discussions.
- Prefer fakes over mocks when you own the abstraction. An in-memory
FakeRepois more durable than 50mockRepo.Setup(...)calls. - Test-induced design damage (DHH's term): when chasing testability creates interface explosion, premature abstraction, or DI gymnastics that worsens the design.
- Mock libraries (.NET 2026): NSubstitute (cleanest), FakeItEasy (clean), Moq (mature but v4.20+ telemetry incident shifted ecosystem trust).
- Where TDD pays off: algorithmic code, well-bounded units, regression-prone modules, defect fixing. Where it doesn't: UI flows, exploratory spikes, glue code over pre-existing libraries.
- Tests as design tool: TDD shapes your API — but you must allow refactoring of the test suite itself, or tests calcify the design.
Concepts (deep dive)
The Red-Green-Refactor loop
1. RED Write a failing test that expresses one new requirement.
2. GREEN Write the simplest production code that makes it pass.
3. REFACTOR Clean up code AND tests, suite still green.
Each cycle is minutes, not hours. The discipline is: never skip a phase, never let red linger, never refactor while red.
// Step 1 — RED
[Fact]
public void Empty_cart_total_is_zero()
{
var cart = new Cart();
Assert.Equal(0m, cart.Total());
}
// (no Cart class yet — won't compile, that's still "red")
// Step 2 — GREEN (simplest)
public class Cart
{
public decimal Total() => 0m;
}
// Step 3 — RED (next requirement)
[Fact]
public void One_item_cart_returns_item_price()
{
var cart = new Cart();
cart.Add(new Item(price: 10m));
Assert.Equal(10m, cart.Total());
}
// Step 4 — GREEN
public class Cart
{
private readonly List<Item> _items = new();
public void Add(Item i) => _items.Add(i);
public decimal Total() => _items.Sum(i => i.Price);
}
// (refactor: extract methods if needed; tests stay green)
The key insight: the simplest passing code is often "wrong" and we know it'll change. That's intentional — the next test forces the right shape. This is "triangulation".
Classic vs London schools
Classic (Detroit / Chicago school): tests verify final state. Use real collaborators where possible; stub only at the I/O boundary.
// Classic style
[Fact]
public void Order_total_includes_tax()
{
var calc = new TaxCalculator(rate: 0.10m); // real collaborator
var order = new Order(calc);
order.AddLine(new Line(100m));
Assert.Equal(110m, order.Total()); // assert state
}
London school (mockist): tests verify interactions. Mock everything outside the unit; assert messages.
// London style
[Fact]
public void Order_consults_calculator_for_each_line()
{
var calc = Substitute.For<ITaxCalculator>();
calc.TaxFor(Arg.Any<decimal>()).Returns(10m);
var order = new Order(calc);
order.AddLine(new Line(100m));
order.Total();
calc.Received(1).TaxFor(100m); // assert interaction
}
When each fits:
| Use | Classic | London |
|---|---|---|
| Pure logic / value objects | ✅ | overkill |
| Coordinating service across boundaries | OK | ✅ |
| Sociable units (small object graphs) | ✅ | overkill |
| Solitary units (must isolate from DB/HTTP) | needs in-memory fake | ✅ |
| Allows refactor without test breakage | better | worse — interactions baked in |
Senior take: classic by default; London when crossing a hard I/O or third-party boundary. Mockist-everywhere creates over-specified tests that break on any refactor.
The test-double taxonomy
From Meszaros's xUnit Test Patterns — these terms are precise:
| Type | Purpose | Example |
|---|---|---|
| Dummy | Passed but never used. Just satisfies a parameter. | null or empty Logger |
| Stub | Returns canned answers. No verification. | clock.UtcNow.Returns(fixedTime) |
| Fake | A working implementation, simplified. In-memory, no I/O. | InMemoryRepo |
| Spy | Records what happened; assert later. | var spy = new SpyEmailSender(); spy.SentMessages |
| Mock | Pre-programmed expectations; verifies calls. | mockA.Verify(x => x.Foo(), Times.Once) |
In casual speech everyone says "mock" — for design conversations, use the right word.
Stub vs Mock — the critical distinction
// STUB — provides canned data, no verification
clock.UtcNow.Returns(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
// (test asserts on the result, not on whether clock was called)
// MOCK — sets expectation that the call happens
mailer.Received(1).Send(Arg.Is<Email>(e => e.To == "[email protected]"));
// (test fails if the call didn't happen, or happened with different args)
Stubs say "if asked, here's the answer." Mocks say "this call must happen exactly this way." Over-mocking = brittle tests.
Fake = the senior's preferred double
public interface IUserRepo
{
Task<User?> Get(int id);
Task Add(User u);
}
public class FakeUserRepo : IUserRepo
{
private readonly Dictionary<int, User> _users = new();
public Task<User?> Get(int id) => Task.FromResult(_users.GetValueOrDefault(id));
public Task Add(User u) { _users[u.Id] = u; return Task.CompletedTask; }
}
Now your tests use a real implementation. No mockRepo.Setup(r => r.Get(5)).Returns(...) ceremony. The fake is reusable across tests and doesn't break when you refactor the interface.
When this is right: the abstraction is small (3-5 methods), behavior is uniform, and you write the fake once.
When this is wrong: the abstraction is huge (50 methods, full data layer) — at that point use a real DB via Testcontainers.
Mock libraries in 2026
// NSubstitute — cleanest C# syntax
var clock = Substitute.For<IClock>();
clock.UtcNow.Returns(_fixedTime);
clock.Received(1).UtcNow;
// Moq — older, ceremonial
var clock = new Mock<IClock>();
clock.Setup(c => c.UtcNow).Returns(_fixedTime);
clock.Verify(c => c.UtcNow, Times.Once);
// FakeItEasy — fluent, similar to NSubstitute
var clock = A.Fake<IClock>();
A.CallTo(() => clock.UtcNow).Returns(_fixedTime);
A.CallTo(() => clock.UtcNow).MustHaveHappenedOnceExactly();
Moq v4.20 telemetry incident (2023): SponsorLink shipped without notice; trust damage. Moq is technically still fine, but many shops migrated to NSubstitute or FakeItEasy. For new projects in 2026, NSubstitute is the safe pick.
Test-induced design damage
A real risk. Pursuit of testability has led to:
- Interface explosion — every concrete class gets a one-implementation interface "for mocking", even when no real polymorphism exists.
- Constructor over-injection — pulling in
IClock,IGuidGenerator,IRandom,IEnvironmentwhen oneTimeProviderwould do. - Premature abstraction — wrapping
File.ReadAllTextbehindIFileSystembefore there's a second implementation. - Method extraction for mocking — exposing internals as virtual methods just to override in tests.
DHH's Test-Induced Design Damage (2014) coined the term. The senior corrective: don't mock unless you must. Use TimeProvider (built-in since .NET 8). Use real types when they're cheap.
TDD pays off — and where it doesn't
| Context | TDD value |
|---|---|
| Algorithmic code (parsing, math, state machines) | High |
| Domain logic with clear invariants | High |
| Bug fix (write failing test for the bug first) | Very high |
| Refactoring with safety net | High |
| Greenfield CRUD with clear shape | Medium |
| UI / front-end interactions | Low (E2E better) |
| Glue over a third-party SDK | Low |
| Exploratory spike (figuring out the API) | Negative |
Bug fix TDD is the highest-ROI pattern: reproduce the bug as a failing test → fix → green → regression-protected forever.
Tests as design tool — and the trap
TDD does shape your API: you naturally arrive at small, focused units because you can't write a test for a 500-line method.
The trap: tests calcify the design. After 200 tests using OrderService.Place(string, string, decimal, int), refactoring to OrderService.Place(PlaceOrderCommand) means rewriting 200 tests. People avoid the refactor.
The senior fix: refactor test code with the same rigor as production code. Extract test builders. Use object mothers. The test suite is a codebase too.
// ❌ 200 tests doing this:
var result = svc.Place("[email protected]", "John", 100m, 1);
// ✅ Object mother / builder
var cmd = OrderBuilder.Default().WithEmail("[email protected]").Build();
var result = svc.Place(cmd);
// Now refactoring Place() means updating the builder, not 200 tests.
A small TDD walkthrough
Requirement: Implement Fizz(n) — returns "Fizz" if n divisible by 3, "Buzz" if by 5, "FizzBuzz" if by 15, else n.ToString().
// === Cycle 1 ===
// RED
[Fact] public void Returns_n_string_for_1() => Assert.Equal("1", Fizz(1));
// GREEN
public static string Fizz(int n) => n.ToString();
// === Cycle 2 ===
// RED
[Fact] public void Returns_Fizz_for_3() => Assert.Equal("Fizz", Fizz(3));
// GREEN
public static string Fizz(int n) => n == 3 ? "Fizz" : n.ToString();
// === Cycle 3 ===
// RED
[Fact] public void Returns_Buzz_for_5() => Assert.Equal("Buzz", Fizz(5));
// GREEN — pattern emerging, generalize
public static string Fizz(int n) =>
n % 3 == 0 ? "Fizz" :
n % 5 == 0 ? "Buzz" :
n.ToString();
// === Cycle 4 ===
// RED
[Fact] public void Returns_FizzBuzz_for_15() => Assert.Equal("FizzBuzz", Fizz(15));
// GREEN — refactor needed
public static string Fizz(int n) =>
(n % 15) switch { 0 => "FizzBuzz", _ => null! }
?? (n % 3 == 0 ? "Fizz" : n % 5 == 0 ? "Buzz" : n.ToString());
// REFACTOR — clean up
public static string Fizz(int n) => (n % 3, n % 5) switch
{
(0, 0) => "FizzBuzz",
(0, _) => "Fizz",
(_, 0) => "Buzz",
_ => n.ToString()
};
Each cycle: 30 seconds. Each cycle: one test, one tiny code change. Triangulation drives the generalization.
How it works under the hood
Substitute.For<IClock>() (NSubstitute) at runtime emits a dynamic proxy class — DispatchProxy / Castle.DynamicProxy under the hood — that overrides every interface method to record calls and forward through a router. Setup configurations (.Returns(x)) attach handlers to the proxy's call router; verification (.Received(n)) queries the recorded call list.
This is why mocking sealed classes / non-virtual concrete methods doesn't work in .NET — there's no method to override. The library can only intercept virtual (or interface) calls. That's why interfaces proliferate when teams over-mock.
Code: correct vs wrong
❌ Wrong: over-specified mock
mockRepo.Verify(r => r.Get(It.IsAny<int>()), Times.Exactly(2));
mockRepo.Verify(r => r.Save(It.IsAny<User>()), Times.Once);
mockRepo.Verify(r => r.Commit(), Times.Once);
mockRepo.VerifyNoOtherCalls();
This breaks on any refactor that legitimately adds a method call. Tests verify implementation, not behavior.
✅ Correct: assert outcome via a fake
var repo = new FakeUserRepo();
var svc = new UserService(repo);
await svc.Register("[email protected]");
Assert.NotNull(await repo.Get("[email protected]")); // outcome, not interaction
❌ Wrong: mocking what you don't own
Mocked EF breaks on any non-trivial query.
✅ Correct: real EF + Sqlite
var options = new DbContextOptionsBuilder<AppDb>().UseSqlite("DataSource=:memory:").Options;
using var ctx = new AppDb(options);
ctx.Database.EnsureCreated();
var svc = new UserService(ctx);
❌ Wrong: 100% coverage as the goal
[Fact]
public void Save_works()
{
_sut.Save(new User());
// no assertion — but coverage tool reports the line as covered
}
Line covered, behavior not verified. Mutation testing exposes this.
✅ Correct: assert behavior
[Fact]
public async Task Save_persists_user()
{
await _sut.Save(new User { Email = "[email protected]" });
var saved = await _sut.Get("[email protected]");
Assert.NotNull(saved);
}
Design patterns for this topic
Pattern 1 — "Red-green-refactor in minutes, not hours"
- Intent: keep cycles tight; never let red linger.
Pattern 2 — "Fake before mock"
- Intent: when you own the abstraction, write a fake; resort to mocks only for last-mile interaction verification.
Pattern 3 — "Bug-fix TDD"
- Intent: every bug becomes a regression test before the fix.
Pattern 4 — "Object mother / test builder"
- Intent: isolate test setup from production constructors so refactors don't shotgun the suite.
Pattern 5 — "Triangulate to generalize"
- Intent: add tests that force the implementation toward the right shape; don't over-engineer the first pass.
Pattern 6 — "Sociable unit"
- Intent: use real collaborators inside the test where they're cheap (value objects, pure logic).
Pros & cons / trade-offs
| Approach | Pros | Cons |
|---|---|---|
| Classic TDD | Test stable across refactor | Harder to isolate I/O |
| London TDD | Crisp unit boundaries | Tests break on refactor |
| Fakes | Reusable, durable | Investment per abstraction |
| Mocks | No upfront investment | Brittle when over-used |
| Stubs | Zero ceremony | Easy to over-stub |
| Spies | Verify side effects naturally | Can be overdone |
When to use / when to avoid
- ✅ Use TDD for bug fixes, algorithmic code, well-bounded units.
- ✅ Use fakes for owned, small abstractions (
IRepo,IClock,IEmailSender). - ✅ Use stubs to inject canned values at I/O boundaries.
- ❌ Avoid TDD for exploratory spikes — write tests after the API stabilizes.
- ❌ Avoid mocks for value objects or pure logic — use real instances.
- ❌ Avoid mocking concrete EF DbContext — use Sqlite/Testcontainers.
- ❌ Avoid London everywhere — over-specifies behavior.
Interview Q&A
Q1. Walk through Red-Green-Refactor. Write a failing test (red), simplest code that passes (green), clean up while suite stays green (refactor). Each cycle minutes long; triangulation drives generalization.
Q2. Classic vs London school? Classic asserts final state with real collaborators where possible. London asserts interactions with mocks at every boundary. Classic for sociable logic; London at hard I/O boundaries.
Q3. List the test-double types. Dummy (placeholder), stub (canned answers), fake (working in-memory impl), spy (records calls), mock (pre-programmed expectations + verification).
Q4. Why prefer fakes over mocks? Fakes are reusable, survive refactors, and exercise real behavior shape. Mocks specify implementation details and break on innocent changes.
Q5. What's test-induced design damage? DHH's term for distortions caused by over-pursuit of testability — interface explosion, premature abstraction, constructor over-injection, virtual-for-mocking.
Q6. Why did teams move from Moq to NSubstitute? Moq v4.20 added SponsorLink telemetry without notice (2023). Trust damaged; many teams migrated. Both technically work; NSubstitute also has cleaner syntax.
Q7. When does TDD not pay off? Exploratory spikes, UI flows, glue code over third-party libraries, design discovery work where the right shape isn't yet clear.
Q8. How do you avoid tests calcifying a design? Refactor the test suite as rigorously as production code: extract builders, object mothers; treat shared test code as production code.
Q9. Stub vs mock — the difference? Stub provides canned data and asserts on outcome. Mock asserts on the call itself — that the method was called with specific args N times.
Q10. Why can't you mock sealed/non-virtual methods? Mock libraries use dynamic proxies that override virtual or interface methods. Sealed/non-virtual concrete methods have no override slot.
Q11. What's bug-fix TDD? Reproduce the bug as a failing test, then fix the code. Test stays in the suite as regression protection.
Q12. Sociable vs solitary unit tests? Sociable: uses real collaborators (cheap value objects). Solitary: every dependency replaced with a double. Classic prefers sociable; London insists on solitary.
Gotchas / common mistakes
- ⚠️ Skipping refactor — tech debt grows; tests become anchors.
- ⚠️ Over-specified mocks (
VerifyNoOtherCalls) → break on innocent refactors. - ⚠️ Mocking everything → tests verify implementation not behavior.
- ⚠️ Interface for every class "to enable mocking" → premature abstraction.
- ⚠️ Tests duplicating production logic → both wrong simultaneously.
- ⚠️ No assertion in test → coverage rises, behavior unverified.
- ⚠️ Letting red linger → defeats the discipline; debug-then-test pattern.
- ⚠️ Refactoring while red → mixing concerns; can't tell what broke.
- ⚠️ Using TDD to design from scratch for unfamiliar problems → prototype first, TDD second.
- ⚠️ Treating tests as immutable → suite calcifies the design.
Further reading
- xUnit Test Patterns — Gerard Meszaros (test-double taxonomy)
- Test-Driven Development by Example — Kent Beck
- Growing Object-Oriented Software, Guided by Tests — Freeman & Pryce (London school canon)
- DHH: Test-Induced Design Damage (2014)
- Mark Seemann: Test doubles
- NSubstitute docs
- Martin Fowler: Mocks Aren't Stubs