EthicsComputerCourses
Join training +91 90532 10052

Mocking & Testable Code

C# · Lesson 37 of 43 Advanced 7 min read Updated

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:

C#
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:

NameWhat it doesExample here
StubReturns fixed answersA repository that always returns two students
FakeA small working implementationAn in-memory SMS sender that stores messages in a list
MockRecords calls so the test can verify them"SendAsync was called once with Priya's number"
DummyPassed only to fill a parameterCancellationToken.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

Terminal
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 NSubstitute

Microsoft.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

C#
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

C#
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 write async void tests.
  • FakeTimeProvider starts 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.CancellationToken is 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.Single checks 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:

C#
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 a Task, 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.Now or DateTime.UtcNow inside business logic. Tests then depend on the calendar. Inject TimeProvider.
  • 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 FeeReminderService and 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.
  • TimeProvider abstracts the clock; FakeTimeProvider sets and advances time in tests.
  • NSubstitute's Substitute.For<T>(), .Returns(...), Arg.Any, Arg.Is and Received(n) cover most mocking needs.
  • Write async tests as async Task and pass TestContext.Current.CancellationToken in 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:

C#
if (s.Due < 100m)
{
    continue; // small amounts are collected at the counter
}

Tests:

C#
[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?

2. What is a "fake" in testing vocabulary?

3. What does await sms.Received(1).SendAsync("9820000002", Arg.Any<string>(), Arg.Any<CancellationToken>()) check?

4. Which test method signature is correct for an async xUnit test?

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