Mocking & Testable Code
In Unit Testing with xUnit the code under test was pure: give Account a number, check the balance. Real classes talk to the outside world. A fee reminder service reads students from a database, sends SMS through a gateway and only runs on certain dates. You cannot send real SMS from a test, and a test that passes only on the 5th of the month is useless.
The solution is to make dependencies replaceable and swap in harmless stand-ins during tests. In this lesson you will design a testable service, test it with hand-written fakes, then with the popular NSubstitute library, and control time with FakeTimeProvider.
Designing for tests: depend on abstractions
A class is easy to test when it receives everything it needs through its constructor, as interfaces or abstract types, instead of creating them itself or calling static methods like DateTime.Now. This is the same idea as dependency injection, which you will wire up automatically in Dependency Injection, Configuration & Logging.
Here is the code under test, in a class library called School.Core:
namespace School.Core;
public record Student(string Name, string Phone, decimal Due);
public record ReminderResult(int Sent, int Failed);
public interface IStudentRepository
{
Task<IReadOnlyList<Student>> GetStudentsWithDuesAsync(CancellationToken ct);
}
public interface ISmsSender
{
Task SendAsync(string phone, string message, CancellationToken ct);
}
public class FeeReminderService(IStudentRepository students, ISmsSender sms, TimeProvider clock)
{
public async Task<ReminderResult> SendRemindersAsync(CancellationToken ct = default)
{
if (clock.GetUtcNow().Day < 5)
{
return new ReminderResult(0, 0); // reminders start on the 5th of each month
}
int sent = 0, failed = 0;
foreach (Student s in await students.GetStudentsWithDuesAsync(ct))
{
try
{
await sms.SendAsync(s.Phone, $"Dear {s.Name}, your fee of Rs {s.Due} is due.", ct);
sent++;
}
catch (HttpRequestException)
{
failed++; // one failed SMS must not stop the others
}
}
return new ReminderResult(sent, failed);
}
}TimeProvider (built into .NET since .NET 8) is an abstract class for "what time is it?". In production you pass TimeProvider.System; in tests you pass a fake clock. Nothing in the service knows whether it talks to a real database or gateway.
Test doubles: the vocabulary
Any stand-in object used in a test is called a test double. You will hear these names:
| Name | What it does | Example here |
|---|---|---|
| Stub | Returns fixed answers | A repository that always returns two students |
| Fake | A small working implementation | An in-memory SMS sender that stores messages in a list |
| Mock | Records calls so the test can verify them | "SendAsync was called once with Priya's number" |
| Dummy | Passed only to fill a parameter | CancellationToken.None |
Libraries such as NSubstitute and Moq can create stubs and mocks for you at run time, but it helps to write a few by hand first.
Walkthrough: testing the reminder service
Step 1: create the test project and add packages
dotnet new xunit3 -o School.Tests
dotnet add School.Tests/School.Tests.csproj reference School.Core/School.Core.csproj
cd School.Tests
dotnet add package Microsoft.Extensions.TimeProvider.Testing
dotnet add package NSubstituteMicrosoft.Extensions.TimeProvider.Testing contains FakeTimeProvider, a clock you can set and move forward. NSubstitute is the mocking library used in step 4.
Step 2: write hand-made doubles
using School.Core;
namespace School.Tests;
internal sealed class StubStudents(IReadOnlyList<Student> list) : IStudentRepository
{
public Task<IReadOnlyList<Student>> GetStudentsWithDuesAsync(CancellationToken ct) =>
Task.FromResult(list);
}
internal sealed class RecordingSms : ISmsSender
{
public List<(string Phone, string Message)> Sent { get; } = [];
public Task SendAsync(string phone, string message, CancellationToken ct)
{
Sent.Add((phone, message));
return Task.CompletedTask;
}
}The stub returns whatever list the test gives it. The recording fake does no network work; it just remembers each message so the test can inspect it.
Step 3: test both sides of the date rule
using Microsoft.Extensions.Time.Testing;
using School.Core;
namespace School.Tests;
public class FeeReminderServiceTests
{
private static readonly Student Aarav = new("Aarav", "9810000001", 4500m);
private static readonly Student Priya = new("Priya", "9820000002", 3000m);
[Fact]
public async Task OnTheFifth_SendsOneSmsPerStudent()
{
var clock = new FakeTimeProvider(new DateTimeOffset(2026, 11, 5, 4, 30, 0, TimeSpan.Zero));
var sms = new RecordingSms();
var service = new FeeReminderService(new StubStudents([Aarav]), sms, clock);
ReminderResult result = await service.SendRemindersAsync(TestContext.Current.CancellationToken);
Assert.Equal(new ReminderResult(1, 0), result);
var (phone, message) = Assert.Single(sms.Sent);
Assert.Equal("9810000001", phone);
Assert.Contains("Rs 4500", message);
}
[Fact]
public async Task BeforeTheFifth_SendsNothing()
{
var clock = new FakeTimeProvider(new DateTimeOffset(2026, 11, 3, 4, 30, 0, TimeSpan.Zero));
var sms = new RecordingSms();
var service = new FeeReminderService(new StubStudents([Aarav, Priya]), sms, clock);
ReminderResult result = await service.SendRemindersAsync(TestContext.Current.CancellationToken);
Assert.Equal(new ReminderResult(0, 0), result);
Assert.Empty(sms.Sent);
}
}Points to notice:
- Test methods can be
async Task; xUnit awaits them. Never writeasync voidtests. FakeTimeProviderstarts at the date you choose, so the tests give the same result on any day.clock.Advance(TimeSpan.FromDays(2))would move it forward, which is handy for testing timers and expiry rules.TestContext.Current.CancellationTokenis xUnit v3's token for the running test; passing it lets a test stop cleanly if the run is cancelled.- Records compare by value, so
Assert.Equal(new ReminderResult(1, 0), result)checks both numbers at once. Assert.Singlechecks that there is exactly one item and returns it.
Step 4: use NSubstitute for failures and verification
Writing a new fake class for every scenario gets tiring, especially "the gateway fails for one number". A mocking library creates the double from the interface at run time:
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using School.Core;
namespace School.Tests;
public class FeeReminderFailureTests
{
[Fact]
public async Task GatewayFailure_IsCounted_AndOthersStillGetSms()
{
var aarav = new Student("Aarav", "9810000001", 4500m);
var priya = new Student("Priya", "9820000002", 3000m);
var students = Substitute.For<IStudentRepository>();
students.GetStudentsWithDuesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Student>>([aarav, priya]));
var sms = Substitute.For<ISmsSender>();
sms.SendAsync("9810000001", Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException(new HttpRequestException("gateway down")));
var clock = new FakeTimeProvider(new DateTimeOffset(2026, 11, 6, 0, 0, 0, TimeSpan.Zero));
var service = new FeeReminderService(students, sms, clock);
ReminderResult result = await service.SendRemindersAsync(TestContext.Current.CancellationToken);
Assert.Equal(new ReminderResult(1, 1), result);
await sms.Received(1).SendAsync(
"9820000002",
Arg.Is<string>(m => m.Contains("Priya")),
Arg.Any<CancellationToken>());
}
}Substitute.For<T>()creates an object that implements the interface. Calls you have not configured return default values; for aTask, that is an already completed task..Returns(...)configures an answer for matching arguments.Arg.Any<T>()matches anything;Arg.Is<T>(predicate)matches values that pass a test.Received(1)verifies that a call happened exactly once with those arguments, and fails the test with a clear message if not.DidNotReceive()checks that a call never happened.
Step 5: run the tests
Run dotnet test from the solution folder. All three tests should pass in well under a second, because nothing touches a real network, database or clock.
Common mistakes
- Using
DateTime.NoworDateTime.UtcNowinside business logic. Tests then depend on the calendar. InjectTimeProvider. - Verifying every single call. Tests that check internal details break whenever the code is refactored. Verify only the behaviour that matters, such as "the SMS was sent".
- Mocking concrete classes. NSubstitute and Moq can only intercept virtual members of classes, so substitute interfaces instead.
- Sharing one mock between tests through a static field. Each test should create its own doubles; xUnit creates a new test class instance per test for this reason.
- Mocking the class under test. Use the real
FeeReminderServiceand fake only its dependencies.
Key points
- Make classes testable by passing dependencies through the constructor as interfaces or abstract types.
- Stubs return fixed answers, fakes are small working implementations, mocks record calls for verification.
TimeProviderabstracts the clock;FakeTimeProvidersets and advances time in tests.- NSubstitute's
Substitute.For<T>(),.Returns(...),Arg.Any,Arg.IsandReceived(n)cover most mocking needs. - Write async tests as
async Taskand passTestContext.Current.CancellationTokenin xUnit v3.
Exercise
Add a rule to FeeReminderService: students whose Due is below Rs 100 are skipped (no SMS, not counted). Write a test using the hand-written StubStudents and RecordingSms that passes Meera (Rs 50) and Rohan (Rs 2,500) on 7 November 2026 and checks that only Rohan received a message. Then write the same check with NSubstitute using DidNotReceive().
Show solution
In the service, inside the loop:
if (s.Due < 100m)
{
continue; // small amounts are collected at the counter
}Tests:
[Fact]
public async Task SmallDues_AreSkipped()
{
var meera = new Student("Meera", "9830000003", 50m);
var rohan = new Student("Rohan", "9840000004", 2500m);
var clock = new FakeTimeProvider(new DateTimeOffset(2026, 11, 7, 0, 0, 0, TimeSpan.Zero));
var sms = new RecordingSms();
var service = new FeeReminderService(new StubStudents([meera, rohan]), sms, clock);
ReminderResult result = await service.SendRemindersAsync(TestContext.Current.CancellationToken);
Assert.Equal(new ReminderResult(1, 0), result);
Assert.Equal("9840000004", Assert.Single(sms.Sent).Phone);
}
[Fact]
public async Task SmallDues_AreSkipped_WithNSubstitute()
{
var meera = new Student("Meera", "9830000003", 50m);
var rohan = new Student("Rohan", "9840000004", 2500m);
var students = Substitute.For<IStudentRepository>();
students.GetStudentsWithDuesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Student>>([meera, rohan]));
var sms = Substitute.For<ISmsSender>();
var clock = new FakeTimeProvider(new DateTimeOffset(2026, 11, 7, 0, 0, 0, TimeSpan.Zero));
await new FeeReminderService(students, sms, clock).SendRemindersAsync(TestContext.Current.CancellationToken);
await sms.DidNotReceive().SendAsync("9830000003", Arg.Any<string>(), Arg.Any<CancellationToken>());
await sms.Received(1).SendAsync("9840000004", Arg.Any<string>(), Arg.Any<CancellationToken>());
}The first test checks the result and the recorded message; the second checks the calls directly. Both pass only if Meera is skipped before any SMS is attempted.
Quick Quiz
1. Why does FeeReminderService take a TimeProvider instead of calling DateTime.UtcNow?
Injecting the clock makes date rules testable and deterministic.
2. What is a "fake" in testing vocabulary?
Fakes really work (for example an in-memory list) but avoid real I/O.
3. What does await sms.Received(1).SendAsync("9820000002", Arg.Any<string>(), Arg.Any<CancellationToken>()) check?
Received verifies calls recorded by the substitute.
4. Which test method signature is correct for an async xUnit test?
xUnit awaits a returned Task; async void hides failures.
Learn C# with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
