Skip to content

Unit-test a service with a mocked dependency

The most common test: one real service, its dependencies replaced with test doubles you drive and verify. This guide builds one end to end using NSubstitute, lifted from the example’s unit suite.

The system under test is a ClientStatusChangedProcessor that maps an incoming message to a save command, dispatches it through a handler, and publishes a confirmation on success. Both collaborators — the handler and the publisher — are substituted.

1. A fixture: the real SUT + substituted dependencies

Section titled “1. A fixture: the real SUT + substituted dependencies”

A fixture declares one composition: the real service, plus substitutes for its direct dependencies. (See the Stage for why it’s one fixture per system-under-test.)

public sealed class ProcessorFixture : BaseStageFixture
{
protected override void ConfigureSubstitutes(ISubstituteCollection substitutes)
{
substitutes.AddSubstitute<IRequestHandler<SaveClientCommand, SaveClientCommandResult>>();
substitutes.AddSubstitute<IKafkaEventPublisher>();
}
protected override void ConfigureServices(IServiceCollection services) =>
services.AddScoped<IClientStatusChangedProcessor, ClientStatusChangedProcessor>();
}

The BaseStageFixture behind this composes a substitute container with a real Microsoft DI container and bridges every substitute into DI, so the real processor resolves the same doubles the test arranges. That bridge is the subject of its own guide — Wire a real DI container; here we just use it.

2. Vocabulary: arrange the doubles, inspect the calls

Section titled “2. Vocabulary: arrange the doubles, inspect the calls”

Arrange verbs build the incoming message and configure the substituted handler:

public static ITestArrange HandlerSucceedsFor(
this ITestArrange arrange, Capture<ClientStatusChangedMessage> message) =>
arrange.Then(host => host.Execute<IRequestHandler<SaveClientCommand, SaveClientCommandResult>>(handler =>
handler.Handle(Arg.Any<SaveClientCommand>(), Arg.Any<CancellationToken>())
.Returns(new SaveClientCommandResult(true, message.Value!.ClientId))));

Inspect verbs verify how the SUT drove those doubles — they only read:

public static ITestInspect HandledUpdate(
this ITestInspect inspect, Capture<ClientStatusChangedMessage> message) =>
inspect.Then(host => host.Execute<IRequestHandler<SaveClientCommand, SaveClientCommandResult>>(handler =>
handler.Received(1).Handle(
Arg.Is<SaveClientCommand>(c =>
c.Operation == SaveOperationKind.Update &&
c.ClientData.Id == message.Value!.ClientId),
Arg.Any<CancellationToken>())));
public static ITestInspect ConfirmationPublishedFor(
this ITestInspect inspect, Capture<ClientStatusChangedMessage> message) =>
inspect.Then(host => host.Execute<IKafkaEventPublisher>(publisher =>
publisher.Received(1).PublishClientEventAsync(message.Value!.ClientId, "updated", Arg.Any<CancellationToken>())));

host.Execute<T>(...) resolves T from the stage. When T is a substituted type, you get the substitute — the very one the real processor will also resolve. See Building your test vocabulary for the verb-authoring patterns.

With the vocabulary in place, the test is three lines of your own language:

public sealed class ClientStatusChangedProcessorTests : BaseUnitTest<ProcessorFixture>
{
public ClientStatusChangedProcessorTests(ProcessorFixture fixture) : base(fixture) { }
[Fact]
public async Task ValidMessage_UpdatesClient_AndPublishesConfirmation()
{
// ARRANGE — an incoming message, and a handler set to succeed for it.
await Arrange
.IncomingStatusChange(out var message, status: (int)ClientStatus.Suspended)
.HandlerSucceedsFor(message);
// ACT — run the real processor over the message.
await Stage.Act().Then(host =>
host.ExecuteAsync<IClientStatusChangedProcessor>(p => p.ProcessAsync(KafkaMessageFaker.ToJson(message.Value!))));
// INSPECT — it dispatched an Update and published the confirmation.
await Inspect
.HandledUpdate(message)
.ConfirmationPublishedFor(message);
}
}

The negative cases read just as clearly — swap HandlerSucceedsForHandlerFailsFor and assert .NoConfirmationPublished(), or feed Process("{ not-valid-json") and assert .NotHandled().