Skip to content

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 FakeRepo is more durable than 50 mockRepo.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:

  1. Interface explosion — every concrete class gets a one-implementation interface "for mocking", even when no real polymorphism exists.
  2. Constructor over-injection — pulling in IClock, IGuidGenerator, IRandom, IEnvironment when one TimeProvider would do.
  3. Premature abstraction — wrapping File.ReadAllText behind IFileSystem before there's a second implementation.
  4. 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

var dbContextMock = new Mock<AppDb>();
dbContextMock.Setup(c => c.Users).Returns(/* fake DbSet */);

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