Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20265 min read
TestingTest DoublesMocking.NETxUnit
Share:𝕏

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

C#
// 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 ID

Stub — Predetermined Return Values

C#
// 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

C#
// 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

C#
// 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

C#
// 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 implementation

When 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 tests

Fake Repository Pattern

C#
// 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 NullReferenceException or returns null. 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.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.