Skip to content
fullstackhero

Guide

Unit tests

xUnit + Shouldly + NSubstitute + AutoFixture for fast in-process tests of handlers, aggregates, and validators — no DbContext, no file system, no network.

views 0 Last updated

Unit tests in fullstackhero are fast, in-process, no infrastructure. They test domain invariants on aggregates, handler logic with substituted dependencies, and validators against constructed commands. No DbContext. No file system. No network. No DI container. Sub-second per project; the entire kit’s unit suite runs in a few seconds.

The stack

LibraryUsed for
xUnit 2.xTest runner, [Fact] / [Theory] discovery
Shouldly 4.xReadable assertions — result.ShouldBe(...), .ShouldThrow<>(), .ShouldSatisfyAllConditions(...)
NSubstitute 5.xMocking service interfaces, verifying calls
AutoFixture 4.xRandom DTO / fixture generation

All four come from the kit’s Directory.Packages.props central package management.

Naming convention

MethodName_Should_ExpectedBehavior_When_Condition. The shape forces you to write the assertion as a sentence:

[Fact]
public async Task Handle_Should_DelegateToRegistrationService_When_CommandIsValid() { /* ... */ }
[Fact]
public void ChangePrice_Should_RaiseDomainEvent_When_NewPriceIsDifferent() { /* ... */ }
[Fact]
public void Resolve_Should_ThrowConflict_When_TicketIsAlreadyClosed() { /* ... */ }

Three rules:

  • Method_Should_... — start with what’s being called.
  • _Should_X — then what the test asserts.
  • _When_Y — then the condition (or omit if the test is a single fact about default behaviour).

Layout: Arrange / Act / Assert

Each test reads top to bottom in three sections, grouped under #region blocks if there are many tests per class:

public sealed class RegisterUserCommandHandlerTests
{
private readonly Fixture _fixture = new();
private readonly IUserRegistrationService _registration = Substitute.For<IUserRegistrationService>();
[Fact]
public async Task Handle_Should_DelegateToRegistrationService_When_CommandIsValid()
{
// Arrange
var command = _fixture.Build<RegisterUserCommand>()
.With(c => c.Email, "valid@example.com")
.With(c => c.Password, "Pa55w0rd!Strong#Enough")
.Create();
var expected = new RegisterUserResponse(Guid.NewGuid(), command.Email);
_registration.RegisterAsync(command, Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(expected));
// Act
var sut = new RegisterUserCommandHandler(_registration);
var actual = await sut.Handle(command, CancellationToken.None);
// Assert
actual.UserId.ShouldBe(expected.UserId);
await _registration.Received(1).RegisterAsync(command, Arg.Any<CancellationToken>());
}
}

Three ground rules

1. Use Shouldly, never raw Assert

// good
result.ShouldBe(42);
result.ShouldNotBeNull();
() => action().ShouldThrow<CustomException>().Message.ShouldContain("conflict");
// not this
Assert.Equal(42, result);
Assert.True(result != null, "result should not be null");

Shouldly’s failure messages carry actual + expected without you writing the assertion message. Less typing, better failure output.

2. Construct the SUT directly; no DI container

var sut = new RegisterUserCommandHandler(_registration); // direct construction
// not this
using var scope = serviceProvider.CreateScope();
var sut = scope.ServiceProvider.GetRequiredService<RegisterUserCommandHandler>();

Unit tests test the handler. If your handler is too coupled to its DI container to be unit-tested in isolation, that’s a sign to refactor — extract the logic, depend on interfaces.

3. No infrastructure, no network, no file system

Anything that touches DbContext, HttpClient, file I/O, or Valkey is an integration test. Unit tests stay in memory.

Testing aggregates

Domain aggregates carry invariants. Test them directly — no handler, no DI:

public sealed class TicketTests
{
[Fact]
public void Resolve_Should_ThrowConflict_When_TicketIsAlreadyClosed()
{
var ticket = Ticket.Create(number: 1, title: "x", description: null,
priority: TicketPriority.Medium, reporterUserId: Guid.NewGuid());
ticket.Resolve(resolutionNote: null);
// ... advance to Closed somehow
var act = () => ticket.Resolve(resolutionNote: "again");
var ex = act.ShouldThrow<CustomException>();
ex.StatusCode.ShouldBe(HttpStatusCode.Conflict);
}
[Fact]
public void AddComment_Should_RaiseDomainEvent_When_TicketIsOpen()
{
var ticket = Ticket.Create(/* ... */);
var commentId = ticket.AddComment(authorUserId: Guid.NewGuid(), body: "Hello");
ticket.DomainEvents.ShouldContain(e => e is TicketCommentAddedDomainEvent);
}
}

ChatChannelTests, MessageTests, and the per-module *Tests projects in src/Tests/{Module}.Tests/Domain/ are full of these. They run in milliseconds and they’re the most direct way to verify an aggregate’s invariants.

Testing validators

public sealed class RegisterUserCommandValidatorTests
{
private readonly IUserService _users = Substitute.For<IUserService>();
private readonly RegisterUserCommandValidator _sut;
public RegisterUserCommandValidatorTests()
{
_users.ExistsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(false));
_sut = new RegisterUserCommandValidator(_users);
}
[Theory]
[InlineData("")]
[InlineData("not-an-email")]
public async Task Validate_Should_Fail_When_EmailIsInvalid(string email)
{
var command = ValidCommandWith(email);
var result = await _sut.ValidateAsync(command);
result.IsValid.ShouldBeFalse();
result.Errors.ShouldContain(e => e.PropertyName == nameof(RegisterUserCommand.Email));
}
private static RegisterUserCommand ValidCommandWith(string email) => /* ... */;
}

[Theory] + [InlineData] is the right pattern for table-driven validator tests.

Verifying mock interactions

NSubstitute’s Received(n) checks how many times a method was called:

await _registration.Received(1).RegisterAsync(command, Arg.Any<CancellationToken>());
await _registration.DidNotReceive().RegisterAsync(/* different command */, Arg.Any<CancellationToken>());

Use this to assert “the handler delegated correctly,” not just “the result happens to be right.” Both matter — a handler that returns the right answer for the wrong reason is a bug waiting to happen.

Running

Terminal window
# A single module's unit tests
dotnet test src/Tests/Identity.Tests/
# Filter by test name
dotnet test --filter "FullyQualifiedName~RegisterUserCommandHandlerTests"

Unit projects run sub-second; no [xunit.runner.json] tweaks needed. Architecture tests (also fast) are covered separately on the architecture tests page.