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");
}
}
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.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.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.