Test Doubles: The Difference Between Dummy, Stub, Spy, Mock and Fake
A test double's kind is decided by its role in the test, not by its library: a dummy fills a slot, a stub supplies data, spies and mocks verify outgoing calls, and a fake is a working shortcut. The Meszaros taxonomy, Fowler's state vs behavior verification, why asserting on stubs hurts, how a fake that does not copy records hid a missing SaveAsync, and FakeTimeProvider and FakeLogger.
Version note: The code in this post was built and run with .NET 10, xUnit.net v3 4.0.2, NSubstitute 6.2.0, Microsoft.Extensions.TimeProvider.Testing 10.11.0 and Microsoft.Extensions.Diagnostics.Testing 10.11.0.
Predict First
When an order is confirmed in Bookstore, OrderConfirmationService saves the order as confirmed, looks the customer up in the customer directory and sends a confirmation email. If the customer has no email address, it writes a warning log:
public sealed partial class OrderConfirmationService(
IOrderRepository orders,
ICustomerDirectory customers,
IEmailSender email,
TimeProvider time,
ILogger<OrderConfirmationService> logger)
{
public async Task<bool> ConfirmAsync(Guid orderId, CancellationToken ct)
{
Order order = await orders.FindAsync(orderId, ct)
?? throw new KeyNotFoundException($"Order {orderId} was not found.");
if (order.Status != OrderStatus.Placed)
{
return false;
}
order.Confirm(time.GetUtcNow());
await orders.SaveAsync(order, ct);
Customer? customer = await customers.FindAsync(order.CustomerId, ct);
if (customer?.Email is null)
{
LogMissingEmail(order.Number, order.CustomerId);
return true;
}
await email.SendAsync(
new EmailMessage(customer.Email, $"Order {order.Number} confirmed", $"Hi {customer.Name}, thank you."),
ct);
return true;
}
[LoggerMessage(Level = LogLevel.Warning, Message = "Order {OrderNumber} confirmed but customer {CustomerId} has no email address")]
private partial void LogMissingEmail(string orderNumber, string customerId);
}
The first test written for this service is green:
[Fact]
public async Task ConfirmAsync_sends_confirmation_email()
{
Order placed = new(Guid.NewGuid(), "BS-1042", Ada.Id);
IOrderRepository orders = Substitute.For<IOrderRepository>();
orders.FindAsync(placed.Id, Arg.Any<CancellationToken>()).Returns(placed);
ICustomerDirectory customers = Substitute.For<ICustomerDirectory>();
customers.FindAsync(Ada.Id, Arg.Any<CancellationToken>()).Returns(Ada);
IEmailSender email = Substitute.For<IEmailSender>();
OrderConfirmationService sut = new(
orders, customers, email, new FakeTimeProvider(Now),
NullLogger<OrderConfirmationService>.Instance);
await sut.ConfirmAsync(placed.Id, TestContext.Current.CancellationToken);
await customers.Received(1).FindAsync(Ada.Id, Arg.Any<CancellationToken>());
await email.Received(1).SendAsync(
Arg.Is<EmailMessage>(m => m.To == "ada@example.com"), Arg.Any<CancellationToken>());
}
There are five test doubles in this test: orders, customers, email, FakeTimeProvider and NullLogger. Which one is a dummy, which a stub, which a mock, which a fake? And one of the two Received lines is both unnecessary and harmful; which one?
The Answer: Not the Name but the Role in the Test
Even though they come from the same library or the same package, their roles in the test differ. NullLogger is a dummy: it is passed because the constructor requires it, and the test never looks at it. orders and customers are stubs: they supply data to the service. email is a mock: the conversation with it decides whether the test passes or fails. FakeTimeProvider is, as its name says, a fake: a working implementation that really keeps time, but is advanced by hand.
The unnecessary and harmful line is customers.Received(1). Finding the customer is not the purpose of the service; it is a step on the way to the purpose. This line turns red the day someone decides to read the customer from a cache, even though the customer still gets the email. Asserting on a stub is the same mistake as the brittle London test of post 01. In this post we separate the kinds of test doubles by name, see when hand-written doubles are better, and look at .NET's ready-made fakes.
The Meszaros Taxonomy: Five Test Doubles
The term "test double" comes from Gerard Meszaros's xUnit Test Patterns (2007); it is the testing counterpart of a stunt double on a film set. Meszaros defines five kinds:
| Kind | What it does | Does the test assert on it? | Bookstore example |
|---|---|---|---|
| Dummy | Only fills the parameter list, never used | No | NullLogger<T>.Instance |
| Stub | Returns canned answers to calls | No | StubCustomerDirectory |
| Spy | Behaves like a stub and records calls | Yes, by inspecting the record | SpyEmailSender |
| Mock | Programmed with expected calls and verifies them | Yes, through expectations | Substitute.For<IEmailSender>() + Received |
| Fake | A working implementation that takes a shortcut | Indirectly, by looking at state | InMemoryOrderRepository, FakeTimeProvider |
Khorikov reduces these five to two groups, because in practice the distinction runs in two directions: stubs emulate incoming interactions (calls the service makes to get data), mocks emulate and examine outgoing interactions (calls the service makes to produce a side effect). Dummies and fakes fall into the stub group, spies into the mock group. The rule follows from this: do not verify incoming calls (queries), verify outgoing calls (commands). customers.FindAsync is a query, email.SendAsync is a command.
Fowler, State and Behavior Verification
Martin Fowler's "Mocks Aren't Stubs" (first published in 2004, last significant update in 2007) describes the same distinction from the angle of verification. State verification: after the Act you look at the state of the system and its collaborators, such as the order in InMemoryOrderRepository being Confirmed. Behavior verification: you check whether the code under test made the right calls to its collaborators, such as Received(1).SendAsync(...). The real subject of Fowler's article is that a mock is not just a tool but part of the mockist testing style we saw in post 01.
Microsoft's "Best practices for writing unit tests" document uses the terms more loosely: "fake" is a general term that can be a stub or a mock, a "mock" is the fake that decides whether the test passes or fails, and a "stub" is a controllable replacement for a dependency. The document itself notes that this usage differs from the narrower definitions in Fowler and Meszaros. That is why the same word circulates with two meanings in the .NET ecosystem: in Microsoft's language a FakeOrder can be a stub or a mock, in Meszaros's language a fake is a working alternative implementation. In this series we use Meszaros's definitions; what matters is having one vocabulary in the team and class names that match roles.
Hand-Written Test Doubles
The same behavior as the test in Predict First, with doubles written without a library:
// Fake: a working implementation that takes a shortcut
public sealed class InMemoryOrderRepository : IOrderRepository
{
private readonly Dictionary<Guid, Order> store = [];
public InMemoryOrderRepository(params Order[] seed)
{
foreach (Order order in seed)
{
store[order.Id] = Copy(order);
}
}
public int SaveCount { get; private set; }
public Task<Order?> FindAsync(Guid id, CancellationToken ct) =>
Task.FromResult(store.TryGetValue(id, out Order? order) ? Copy(order) : null);
public Task SaveAsync(Order order, CancellationToken ct)
{
store[order.Id] = Copy(order);
SaveCount++;
return Task.CompletedTask;
}
// Like a real database: unsaved changes are not visible
private static Order Copy(Order o) => new(o.Id, o.Number, o.CustomerId, o.Status, o.ConfirmedAt);
}
// Stub: returns canned answers, the test does not assert on it
public sealed class StubCustomerDirectory(params Customer[] customers) : ICustomerDirectory
{
public Task<Customer?> FindAsync(string customerId, CancellationToken ct) =>
Task.FromResult(customers.FirstOrDefault(c => c.Id == customerId));
}
// Spy: records calls, the test inspects the record afterwards
public sealed class SpyEmailSender : IEmailSender
{
public List<EmailMessage> Sent { get; } = [];
public Task SendAsync(EmailMessage message, CancellationToken ct)
{
Sent.Add(message);
return Task.CompletedTask;
}
}
public sealed class OrderConfirmationTests
{
private static readonly DateTimeOffset Now = new(2026, 11, 8, 9, 30, 0, TimeSpan.Zero);
private static readonly Customer Ada = new("C-1", "Ada", "ada@example.com");
private readonly Order placed = new(Guid.NewGuid(), "BS-1042", Ada.Id);
private readonly SpyEmailSender email = new();
private readonly FakeTimeProvider time = new(Now);
[Fact]
public async Task Confirmed_order_is_saved_with_confirmation_time()
{
InMemoryOrderRepository orders = new(placed);
OrderConfirmationService sut = new(
orders, new StubCustomerDirectory(Ada), email, time,
NullLogger<OrderConfirmationService>.Instance);
await sut.ConfirmAsync(placed.Id, TestContext.Current.CancellationToken);
Order? saved = await orders.FindAsync(placed.Id, TestContext.Current.CancellationToken);
Assert.Equal(OrderStatus.Confirmed, saved?.Status);
Assert.Equal(Now, saved?.ConfirmedAt);
}
[Fact]
public async Task Customer_receives_one_confirmation_email()
{
OrderConfirmationService sut = new(
new InMemoryOrderRepository(placed), new StubCustomerDirectory(Ada), email, time,
NullLogger<OrderConfirmationService>.Instance);
await sut.ConfirmAsync(placed.Id, TestContext.Current.CancellationToken);
EmailMessage message = Assert.Single(email.Sent);
Assert.Equal("ada@example.com", message.To);
Assert.Equal("Order BS-1042 confirmed", message.Subject);
}
}
A hand-written double has three advantages. Readability: Assert.Single(email.Sent) followed by two Assert.Equal calls reads more easily than an Arg.Is<EmailMessage>(m => ...) lambda; when the message is wrong, the failure message also shows which field is wrong. Reuse: SpyEmailSender is the same in the tests of every service that sends email. Refactoring safety: when a method is added to the interface, the compiler points to every double.
The disadvantage is clear as well: one class per interface. If the interface has ten methods and the test uses only one, writing it by hand is wasteful; in that case a mocking library is more practical, which is the topic of the next post. A practical rule: when you start setting up the same double with a library for the third time, consider turning it into a hand-written class.
When the Fake Drifts from Production
The Copy method in InMemoryOrderRepository is not decoration. I wrote a naive version of the same repository that keeps and returns the very object it was given:
// Naive fake: keeps and returns the very object it was given
public sealed class NaiveInMemoryOrderRepository(params Order[] seed) : IOrderRepository
{
private readonly Dictionary<Guid, Order> store = seed.ToDictionary(o => o.Id);
public Task<Order?> FindAsync(Guid id, CancellationToken ct) =>
Task.FromResult(store.GetValueOrDefault(id));
public Task SaveAsync(Order order, CancellationToken ct)
{
store[order.Id] = order;
return Task.CompletedTask;
}
}
Then I commented out the await orders.SaveAsync(order, ct); line in the service; in other words, in production the confirmation is never saved. The result of the same "order is saved as confirmed" test with the two fakes:
failed Bookstore.Ordering.Tests.OrderConfirmationTests.Confirmed_order_is_saved_with_confirmation_time (0ms)
Expected: Confirmed
Actual: Placed
total: 4
failed: 1
succeeded: 3
The copying fake caught the bug. The test written with the naive fake (Confirmed_order_is_saved_naive_fake), on the other hand, stayed green: the service changed the order object itself, the fake returned the same reference, and the test saw it as "saved" even though nothing was saved. In a real database, unsaved changes are lost; if the fake does not imitate this basic behavior, the tests verify a world that does not exist in production.
So the rule for writing a fake is this: a fake must imitate the production behaviors the test relies on. For a repository that means at least: unsaved changes are not visible, a missing record returns null, and a second save with the same key overwrites the first. If constraints like a production unique index do not exist in the fake, tests for those constraints should be written not with the fake but with the real database tests described in post 22 of the Entity Framework Core (EF Core) series.
Ready-Made Fakes: FakeTimeProvider and FakeLogger
The TimeProvider abstract class that came with .NET 8 became the standard way to take time from outside. The test side is FakeTimeProvider in the Microsoft.Extensions.TimeProvider.Testing package: when created without parameters it shows midnight (UTC) on 1 January 2000, it is moved forward with Advance and set with SetUtcNow. In the tests above, new FakeTimeProvider(Now) let us compare ConfirmedAt with an exact value; with code that uses DateTime.UtcNow, that assert would be impossible. Timers, Task.Delay and skipping time with Advance are the topic of post 11.
For verifying logs, there is FakeLogger<T> in the Microsoft.Extensions.Diagnostics.Testing package. It stores every log record in a readable form through its Collector:
[Fact]
public async Task Missing_email_is_logged_as_warning_and_nothing_is_sent()
{
Customer noEmail = Ada with { Email = null };
FakeLogger<OrderConfirmationService> logger = new();
OrderConfirmationService sut = new(
new InMemoryOrderRepository(placed), new StubCustomerDirectory(noEmail), email, time, logger);
await sut.ConfirmAsync(placed.Id, TestContext.Current.CancellationToken);
Assert.Empty(email.Sent);
FakeLogRecord record = Assert.Single(logger.Collector.GetSnapshot());
Assert.Equal(LogLevel.Warning, record.Level);
Assert.Equal("Order BS-1042 confirmed but customer C-1 has no email address", record.Message);
}
The reason to prefer FakeLogger over an ILogger mock is technical: methods like LogInformation and LogWarning are extension methods and cannot be mocked; what gets mocked is the generic Log<TState> method, and verifying it needs an unreadable expression. FakeLogger gives you the records as plain objects: level, formatted message, event id, structured state. The question of which logs are worth verifying comes up in post 11.
In Production
Keep shared fakes in a common folder of the test project, and give them tests too. If InMemoryOrderRepository is used in ten test classes, a bug in it misleads the tests of ten classes. A few tests for the fake itself ("unsaved changes are not visible") are cheap insurance. The more ambitious version is running the same set of tests against both the fake and the real implementation (a contract test); its real database side belongs to the Integration ve E2E (end-to-end) Test series.
Align the version of ready-made fake packages with the runtime. The Microsoft.Extensions.*.Testing packages belong to the same family as .NET's extension packages; 10.x packages cause no surprises on the 10.x runtime when used with the same major version.
Common Pitfalls
Asserting on a stub. The customers.Received(1) line in Predict First. Verifying that a collaborator that answers a query was called locks in how the result was obtained. A scenario in which the stub is not called produces a wrong result anyway, and an assert on the result catches it.
Wrong name, wrong intent. If a class called MockCustomerDirectory contains only canned answers, someone reading the test looks for a verification on it somewhere. The Microsoft documentation gives the same warning: if you call your stubs "mocks", other developers make false assumptions about your intent. Name classes by role: Stub..., Spy..., InMemory....
The fake drifting from production behavior. The naive fake above. Another common form: the production repository pages its results while the fake returns the whole list, or a search that is case-insensitive in production is case-sensitive in the fake. When writing a fake, ask "what does production do with this call?" for each method separately.
Faking everything. Writing doubles for domain objects like Order, Customer and PriceCalculator. These are deterministic code running in-process; use the real ones. Test doubles are for the system boundary.
A Small Experiment
★☆☆ Remove the customers.Received(1) line from the Predict First test and replace email.Received with SpyEmailSender. How many lines shorter is the test, and how did the failure message change?
★★☆ Write tests for InMemoryOrderRepository itself: verify that unsaved changes are not visible, that a missing id returns null, and that a second save overwrites the first. Which of these does the naive fake fail?
★★★ Search your own project for class names starting with "Mock". For each one, determine its actual role in the test (dummy, stub, spy, mock, fake) and rename it by role. How many turned out to be stubs?
In the next post we move to libraries: argument matching with NSubstitute, Moq and FakeItEasy, sequential returns, async methods, call count and order, strict and loose behavior, and what Moq's 2023 SponsorLink incident teaches about choosing dependencies.