Unit testing with fakes
Stubs, fakes, spies and mocks, injecting TimeProvider instead of reading the clock, and where EF Core's in-memory provider misleads you.
After this lesson you can
- Name the kinds of test double and pick the right one
- Make time-dependent code testable with TimeProvider
- Explain why the EF Core in-memory provider is a poor test database
- Describe how WebApplicationFactory swaps services for an integration test
A unit test exercises one class with its dependencies replaced by test doubles. The words are used loosely, but they mean different things:
- a dummy is passed only to satisfy a signature;
- a stub returns canned answers;
- a fake is a working, simplified implementation, such as an in-memory repository;
- a spy records how it was called, for the test to inspect;
- a mock is set up with expectations and verifies them, usually through a library such as Moq or NSubstitute.
Constructor injection is what makes this possible: a class that asks
for IEmailSender can be given a spy, while one that calls
new SmtpClient() cannot.
public class TrialTests
{
[Theory]
[InlineData(13, true)]
[InlineData(14, false)]
public void Trial_lasts_fourteen_days(int days, bool expected) { /* ... */ }
}
Time is a dependency
Code that reads DateTime.UtcNow directly cannot be tested at day 13
and day 14 without waiting. Since .NET 8 the base class library has an
abstraction for this: inject TimeProvider (register
TimeProvider.System in production) and override GetUtcNow() in
tests. The Microsoft.Extensions.TimeProvider.Testing package ships a
ready-made FakeTimeProvider; the example writes its own in a few lines.
public class ManualClock(DateTimeOffset start) : TimeProvider{ public DateTimeOffset Now { get; set; } = start; public override DateTimeOffset GetUtcNow() => Now;} public class TrialService(TimeProvider clock){ public bool IsActive(DateTimeOffset startedAt) => clock.GetUtcNow() < startedAt.AddDays(14);} public static class Solution{ public static List<string> Run() { var start = new DateTimeOffset(2025, 1, 1, 9, 0, 0, TimeSpan.Zero); var clock = new ManualClock(start); var trial = new TrialService(clock); var results = new List<string>(); foreach (var day in new[] { 0, 13, 14, 30 }) { clock.Now = start.AddDays(day); results.Add($"day {day}: {(trial.IsActive(start) ? "active" : "ended")}"); } return results; }}Spies, and testing outcomes
public interface IEmailSender { void Send(string to, string subject); }public interface IPayments { bool Charge(string customer, decimal amount); } public class SpyEmailSender : IEmailSender{ public List<string> Sent = new(); public void Send(string to, string subject) => Sent.Add(to + ": " + subject);} public class StubPayments(bool succeeds) : IPayments{ public bool Charge(string customer, decimal amount) => succeeds;} public class Checkout(IPayments payments, IEmailSender email){ public bool Place(string customer, decimal amount) { var paid = payments.Charge(customer, amount); email.Send(customer, "Order confirmed"); // bug: sent even when the charge failed return paid; }} public static class Solution{ static string Test(string name, bool paymentSucceeds, int expectedEmails) { var spy = new SpyEmailSender(); new Checkout(new StubPayments(paymentSucceeds), spy).Place("ann@x.io", 20m); return $"{name}: {(spy.Sent.Count == expectedEmails ? "PASS" : "FAIL")} ({spy.Sent.Count} sent)"; } public static List<string> Run() => new() { Test("confirms a paid order", paymentSucceeds: true, expectedEmails: 1), Test("sends nothing when the charge fails", paymentSucceeds: false, expectedEmails: 0), };}Prefer asserting on outcomes (what was saved, what was returned, what was sent) over asserting on every call made. A test that pins the exact sequence of internal calls breaks on every refactoring and proves little.
Databases and the web host
EF Core's in-memory provider is not a relational database: it has no transactions, does not enforce foreign keys or unique indexes, and evaluates queries differently from SQL. A test can pass against it and the same code fail against SQL Server. Use a repository fake for unit tests, and a real engine (SQLite in-memory, or the production engine in a container) for tests of data access itself.
For tests of the HTTP surface, WebApplicationFactory<Program> (package
Microsoft.AspNetCore.Mvc.Testing) boots the real pipeline in memory and
gives you an HttpClient; ConfigureTestServices replaces individual
registrations, such as the email sender, with doubles.
Common mistakes
- Reading
DateTime.UtcNoworGuid.NewGuid()inside business logic, then writing tests that pass only at certain times of day. - Treating a green test run against the in-memory provider as proof that queries and constraints work.
- Verifying every interaction with a mock. The test then describes the implementation instead of the behaviour.
- Sharing mutable state between tests through static fields. xUnit creates a new instance of the test class for every test, but statics survive and tests can run in parallel across classes.
What interviewers ask
- What is the difference between a stub, a fake and a mock? Canned answers, a working lightweight implementation, and verified expectations.
- How do you test code that depends on the current time? Inject
TimeProvider(or a clock interface) and control it from the test.
Exercise · Pro
Drive a session store with a fake clock
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade