Test Doubles — Mocks, Fakes, Stubs, and When to Use Each
The five types of test doubles: dummies, stubs, spies, mocks, and fakes — what each is for, how to implement them in .NET, and the rules for choosing the right one.
The Five Test Double Types
Gerard Meszaros's taxonomy (from xUnit Test Patterns):
Dummy: passed but never used — satisfies a parameter requirement
Stub: provides predetermined responses to calls
Spy: records calls for later verification
Mock: verifies expected behavior during the test
Fake: working implementation, but simplified (not for production)Colloquially, "mock" is used for all five — but the distinctions matter for test design.
Dummy — Satisfy a Parameter
// CreatePatientCommand requires an issuing doctor ID
// In this test, the issuing doctor is irrelevant to what we're testing
var dummyDoctorId = Guid.NewGuid(); // dummy — satisfies parameter, never used in assertion
var cmd = new CreatePatientCommand("John Smith", new DateOnly(1985, 3, 15), "MRN-001", dummyDoctorId);
var result = await _sut.Handle(cmd, CancellationToken.None);
result.IsSuccess.Should().BeTrue(); // test is about creation success, not doctor IDStub — Predetermined Return Values
// Stub: controls what dependencies return — no verification
var repo = Substitute.For<IPatientRepository>();
// Configure return values — this is stubbing
repo.ExistsByMRNAsync("MRN-001", Arg.Any<CancellationToken>())
.Returns(false); // "pretend MRN does not exist"
repo.GetByIdAsync(patientId, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Patient?>(existingPatient));
// We DO NOT verify how many times these were called — only the result matters
var result = await _sut.Handle(createCmd, CancellationToken.None);
result.IsSuccess.Should().BeTrue();Stubs answer the question: "What does the system do when the dependency returns X?"
Spy — Record Calls for Verification
// Spy: records how it was called so you can verify later
public sealed class SpyEmailService : IEmailService
{
public List<(string To, string Subject, string Body)> SentEmails { get; } = [];
public Task SendAsync(string to, string subject, string body, CancellationToken ct)
{
SentEmails.Add((to, subject, body));
return Task.CompletedTask;
}
}
// In test
var spy = new SpyEmailService();
var sut = new CreatePatientHandler(_repo, _uow, spy);
await sut.Handle(cmd, CancellationToken.None);
// Verify recorded calls
spy.SentEmails.Should().HaveCount(1);
spy.SentEmails[0].To.Should().Be("john.smith@hospital.com");
spy.SentEmails[0].Subject.Should().Contain("Welcome");Spies are hand-written — NSubstitute's .Received() is a spy built into the framework.
Mock — Verifies Expected Behavior Upfront
// Mock (NSubstitute): configure expectations AND verify
var repo = Substitute.For<IPatientRepository>();
// Arrange expectations
repo.ExistsByMRNAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(false);
// Act
await _sut.Handle(cmd, CancellationToken.None);
// Verify — the "mock" part
await repo.Received(1).AddAsync(
Arg.Is<Patient>(p => p.MRN == "MRN-001"),
Arg.Any<CancellationToken>());
await _uow.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());Mocks verify: "did the system interact with its dependencies correctly?"
Fake — Simplified Working Implementation
// Fake: works like the real thing, but simplified
// Use when: the real implementation is too complex/slow for unit tests
// Real: connects to Redis, network calls
public class FakeDistributedCache : IDistributedCache
{
private readonly Dictionary<string, byte[]> _store = [];
public byte[]? Get(string key)
=> _store.TryGetValue(key, out var v) ? v : null;
public Task<byte[]?> GetAsync(string key, CancellationToken token = default)
=> Task.FromResult(Get(key));
public void Set(string key, byte[] value, DistributedCacheEntryOptions options)
=> _store[key] = value;
public Task SetAsync(string key, byte[] value,
DistributedCacheEntryOptions options, CancellationToken token = default)
{
Set(key, value, options);
return Task.CompletedTask;
}
public void Remove(string key) => _store.Remove(key);
public Task RemoveAsync(string key, CancellationToken token = default)
{
Remove(key);
return Task.CompletedTask;
}
public void Refresh(string key) { }
public Task RefreshAsync(string key, CancellationToken token = default)
=> Task.CompletedTask;
}Fakes are shared across many tests. They are production-quality implementations that trade completeness for speed.
Choosing the Right Test Double
What do you need? Use
────────────────────────────────────────────────────
Satisfy a parameter Dummy (literal value, null, empty)
Control return values Stub (NSubstitute .Returns())
Verify calls without setup Spy (NSubstitute .Received())
Verify calls with setup Mock (NSubstitute — combines both)
Lightweight working impl Fake (hand-written class)
Real behavior in tests Use the real implementationWhen to Use Real Implementations
Not every dependency needs a test double:
Value objects (Dosage, Money, PatientId):
Use the real class — testing the real domain logic is the point
Domain entities:
Use the real class — entity invariants should be exercised
Simple helpers (date formatting, string manipulation):
Use the real class
Infrastructure (DB, Redis, HTTP):
Use test doubles — real behavior tested in integration testsFake Repository Pattern
// FakePatientRepository — in-memory store, used in fast unit tests
public sealed class FakePatientRepository : IPatientRepository
{
private readonly List<Patient> _store = [];
public Task AddAsync(Patient patient, CancellationToken ct)
{
_store.Add(patient);
return Task.CompletedTask;
}
public Task<Patient?> GetByIdAsync(PatientId id, CancellationToken ct)
=> Task.FromResult(_store.FirstOrDefault(p => p.Id == id));
public Task<bool> ExistsByMRNAsync(string mrn, CancellationToken ct)
=> Task.FromResult(_store.Any(p => p.MRN == mrn));
// Test helper — not on the interface
public void Seed(params Patient[] patients) => _store.AddRange(patients);
public IReadOnlyList<Patient> All => _store.AsReadOnly();
}Red Flag / Green Answer
Red Flag: "We use Substitute.For<IPatientRepository>() for every test and configure .Returns() for every method, even the ones we do not use in that test."
Over-configured mocks are fragile. When the handler changes to use a new repository method, every test that did not configure that method throws
NullReferenceExceptionor returnsnull. Use a Fake repository that has sensible defaults and only configure what the specific test cares about.
Green Answer:
Fake repository as the default — add patients with
.Seed(), call methods that work. Use NSubstitute mocks only when you need to verify interaction behavior (was this method called?) or need to control return values precisely.
Key Takeaway
The five test doubles serve different purposes: dummies satisfy parameters, stubs control responses, spies record calls, mocks verify interactions, fakes provide lightweight implementations. NSubstitute covers stubs, spies, and mocks. Hand-write fakes for complex infrastructure interfaces used across many tests. Use real domain objects (entities, value objects) — they are the thing being tested, not a dependency to replace.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.