Test Doubles Patterns — When to Mock vs Stub vs Fake vs Spy

The “test double” vocabulary (mocks, stubs, fakes, spies) comes from Gerard Meszaros’s xUnit Test Patterns. Understanding the distinctions helps choose the right tool for each scenario. The BlogApp often benefits from a fake — an in-memory IPostsRepository that actually stores and retrieves data — for complex tests where configuring many mock setups becomes unreadable. Fakes are more code to write but produce more resilient, maintainable tests for stateful interaction scenarios.

Test Double Types and InMemoryPostsRepository Fake

// ── Test Double types — practical examples ────────────────────────────────

// DUMMY — passed but never used
await _sut.GetByIdAsync(1, CancellationToken.None);  // None = dummy token

// STUB — returns canned values, no behaviour verification
var stubRepo = new Mock<IPostsRepository>();
stubRepo.Setup(r => r.GetBySlugAsync("test", default))
        .ReturnsAsync(new Post { Id = 1 });  // always returns this post

// FAKE — simplified working implementation (stores real data)
public class InMemoryPostsRepository : IPostsRepository
{
    private readonly List<Post>      _posts    = new();
    private readonly List<Comment>   _comments = new();
    private int _nextId = 1;

    public Task<Post?> GetByIdAsync(int id, CancellationToken ct)
        => Task.FromResult(_posts.FirstOrDefault(p => p.Id == id));

    public Task<Post?> GetBySlugAsync(string slug, CancellationToken ct)
        => Task.FromResult(_posts.FirstOrDefault(p => p.Slug == slug));

    public Task<bool> SlugExistsAsync(string slug, CancellationToken ct)
        => Task.FromResult(_posts.Any(p => p.Slug == slug));

    public Task<Post> AddAsync(Post post, CancellationToken ct)
    {
        post.Id = _nextId++;
        _posts.Add(post);
        return Task.FromResult(post);
    }

    public Task UpdateAsync(Post post, CancellationToken ct)
    {
        var idx = _posts.FindIndex(p => p.Id == post.Id);
        if (idx >= 0) _posts[idx] = post;
        return Task.CompletedTask;
    }

    public Task<IReadOnlyList<Post>> GetPublishedAsync(
        int page, int size, string? category, CancellationToken ct)
    {
        var query = _posts.Where(p => p.IsPublished);
        if (category != null)
            query = query.Where(p => p.CategorySlug == category);
        var result = query.OrderByDescending(p => p.PublishedAt)
                         .Skip((page - 1) * size).Take(size)
                         .ToList() as IReadOnlyList<Post>;
        return Task.FromResult(result);
    }

    // Seed test data
    public void Seed(params Post[] posts) => _posts.AddRange(posts);
}

// ── Using the fake in tests ───────────────────────────────────────────────
public class PostsService_WithFake_Tests
{
    private readonly InMemoryPostsRepository _fakeRepo = new();
    private readonly Mock<ICurrentUserService> _user   = new();
    private readonly PostsService               _sut;

    public PostsService_WithFake_Tests()
    {
        _user.SetupGet(u => u.UserId).Returns("author-123");
        _sut = new PostsService(_fakeRepo, _user.Object);
    }

    [Fact]
    public async Task GetPublishedAsync_ReturnsOnlyPublishedPosts()
    {
        // Seed the fake with realistic test data
        _fakeRepo.Seed(
            new PostBuilder().Published().WithSlug("published-1").Build(),
            new PostBuilder().Published().WithSlug("published-2").Build(),
            new PostBuilder().WithStatus("draft").WithSlug("draft-post").Build()
        );

        var result = await _sut.GetPublishedAsync(1, 10, null, default);

        result.Items.Should().HaveCount(2);
        result.Items.Should().AllSatisfy(p => p.IsPublished.Should().BeTrue());
        result.Items.Should().NotContain(p => p.Slug == "draft-post");
    }
}
Note: Fakes are the right choice when tests involve multiple operations on the same repository (create, then read, then update — each verifying the state changed correctly). Configuring Moq or NSubstitute for these stateful sequences requires complex SetupSequence and Callback chains that are harder to read than the fake’s straightforward in-memory list. The trade-off: fakes require more implementation code upfront but result in cleaner, more maintainable tests for complex interaction scenarios.
Tip: The in-memory fake’s Seed() method sets up the initial state for each test cleanly. Because the fake is instantiated fresh per test class (not shared), there is no state bleed between tests. For tests that need different initial data, call Seed() with different posts. The fake accurately models the real repository’s behaviour (pagination, ordering, filtering) without a database — it is fast, deterministic, and isolated.
Warning: A fake must faithfully implement the real repository’s behaviour — otherwise tests pass with the fake but fail with the real repository. If the real GetPublishedAsync orders by PublishedAt DESC, the fake must too. If the real implementation has case-insensitive slug matching, the fake must too. Bugs in the fake produce false positives — tests pass but production code fails. Review the fake’s implementation whenever the real implementation’s contract changes.

Test Double Comparison

Type Description BlogApp Use Case When to Use
Dummy Passed but not used CancellationToken.None Satisfying required parameters
Stub Returns canned responses Mock repo returning fixed post Simple, isolated tests
Fake Working simplified implementation InMemoryPostsRepository Stateful, multi-step tests
Mock Verifies interactions Email service verify call count Side effects without return value
Spy Wraps real object, records calls Decorator over real repo Monitoring production-like objects

Common Mistakes

Mistake 1 — Using mocks for everything (complex mock setups for stateful tests)

❌ Wrong — 20 Moq setups to simulate create→read→update→delete flow; unreadable test setup.

✅ Correct — use a fake for stateful interaction tests; only use mocks for simple single-interaction tests.

Mistake 2 — Fake doesn’t faithfully implement the real interface (false positives)

❌ Wrong — fake returns posts in insertion order; real repo returns in PublishedAt DESC order; test passes but ordering bug exists.

✅ Correct — fake precisely models the real implementation’s observable contract including ordering, filtering, and pagination.

🧠 Test Yourself

A test uses the InMemoryPostsRepository fake and seeds 5 posts. The test calls CreateAsync (adds a 6th post) then GetPublishedAsync. The fake returns 3 published posts. Does the fake need to be reset between test methods?