Unit Testing Application Handlers — xUnit v3 and FluentAssertions
How to write unit tests for Clean Architecture command and query handlers: xUnit v3, FluentAssertions, fake implementations vs mocking, in-memory EF Core, and the tests that actually catch production bugs.
The Unit Testing Target
What to unit test:
✓ Domain entities — invariant enforcement, factory methods, state machines
✓ Application handlers — business logic, error paths, success paths
✓ Value objects — validation, equality
What NOT to unit test:
✗ Controllers (test them via integration tests or just the handler they delegate to)
✗ Infrastructure repositories (use integration tests with real or in-memory DB)
✗ FluentValidation validators in isolation (test them through the handler)Setup
<!-- tests/SystemForge.Application.UnitTests/SystemForge.Application.UnitTests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
<PackageReference Include="FluentAssertions" Version="7.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Application\SystemForge.Application.csproj" />
<ProjectReference Include="..\..\src\Infrastructure\SystemForge.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\Domain\SystemForge.Domain.csproj" />
</ItemGroup>
</Project>Fake vs Mock
Fakes are preferred over mocks in Clean Architecture because they exercise the real interface behavior:
// Fakes/FakePatientRepository.cs — a simple in-memory implementation
public sealed class FakePatientRepository : IPatientRepository
{
private readonly Dictionary<PatientId, Patient> _store = new();
public Task<Patient?> GetByIdAsync(PatientId id, CancellationToken ct)
=> Task.FromResult(_store.GetValueOrDefault(id));
public Task<Patient?> GetByMRNAsync(string mrn, CancellationToken ct)
=> Task.FromResult(_store.Values.FirstOrDefault(p => p.MRN == mrn));
public Task<bool> ExistsByMRNAsync(string mrn, CancellationToken ct)
=> Task.FromResult(_store.Values.Any(p => p.MRN == mrn));
public Task AddAsync(Patient patient, CancellationToken ct)
{
_store[patient.Id] = patient;
return Task.CompletedTask;
}
public Task<IReadOnlyList<Patient>> GetActiveAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<Patient>>(
_store.Values.Where(p => p.IsActive).ToList());
// Utility for test setup
public void Seed(Patient patient) => _store[patient.Id] = patient;
}
// Fakes/FakeUnitOfWork.cs
public sealed class FakeUnitOfWork : IUnitOfWork
{
public int SaveChangesCallCount { get; private set; }
public Task<int> SaveChangesAsync(CancellationToken ct)
{
SaveChangesCallCount++;
return Task.FromResult(1);
}
}Testing a Command Handler — Happy Path and Failure Paths
// tests/Application.UnitTests/Patients/CreatePatientCommandHandlerTests.cs
using FluentAssertions;
using Xunit;
public class CreatePatientCommandHandlerTests
{
private readonly FakePatientRepository _patients = new();
private readonly FakeUnitOfWork _unitOfWork = new();
private readonly CreatePatientCommandHandler _sut;
public CreatePatientCommandHandlerTests()
{
_sut = new CreatePatientCommandHandler(_patients, _unitOfWork);
}
[Fact]
public async Task Handle_valid_command_should_return_success_with_patient_id()
{
// Arrange
var command = new CreatePatientCommand(
Name: "John Smith",
DateOfBirth: new DateOnly(1985, 3, 15),
MRN: "MRN-001");
// Act
var result = await _sut.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBe(PatientId.Empty);
_unitOfWork.SaveChangesCallCount.Should().Be(1);
}
[Fact]
public async Task Handle_duplicate_mrn_should_return_failure()
{
// Arrange
var existing = Patient.Create("Existing Patient", new DateOnly(1980, 1, 1), "MRN-001").Value;
_patients.Seed(existing);
var command = new CreatePatientCommand("New Patient", new DateOnly(1990, 5, 10), "MRN-001");
// Act
var result = await _sut.Handle(command, CancellationToken.None);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Be(PatientErrors.MRNAlreadyExists);
_unitOfWork.SaveChangesCallCount.Should().Be(0); // no save on failure
}
[Fact]
public async Task Handle_empty_name_should_return_failure()
{
// Arrange
var command = new CreatePatientCommand("", new DateOnly(1985, 3, 15), "MRN-002");
// Act
var result = await _sut.Handle(command, CancellationToken.None);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be(PatientErrors.NameRequired.Code);
}
}Testing Domain Entities Directly
// tests/Application.UnitTests/Domain/PatientTests.cs
public class PatientTests
{
[Fact]
public void Create_valid_patient_raises_domain_event()
{
// Act
var result = Patient.Create("John Smith", new DateOnly(1985, 3, 15), "MRN-001");
// Assert
result.IsSuccess.Should().BeTrue();
var events = result.Value.PopDomainEvents();
events.Should().ContainSingle()
.Which.Should().BeOfType<PatientRegisteredDomainEvent>();
}
[Fact]
public void AddPrescription_to_inactive_patient_returns_error()
{
// Arrange
var patient = Patient.Create("John Smith", new DateOnly(1985, 3, 15), "MRN-001").Value;
patient.Deactivate();
var prescription = BuildValidPrescription();
// Act
var result = patient.AddPrescription(prescription);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Be(PatientErrors.InactivePatient);
}
[Fact]
public void AddPrescription_duplicate_active_prescription_returns_error()
{
// Arrange
var patient = Patient.Create("John Smith", new DateOnly(1985, 3, 15), "MRN-001").Value;
var rx = BuildValidPrescription(medicationCode: "WARFARIN");
patient.AddPrescription(rx); // first add — succeeds
// Act
var result = patient.AddPrescription(BuildValidPrescription(medicationCode: "WARFARIN"));
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Be(PatientErrors.DuplicateActivePrescription);
}
private static Prescription BuildValidPrescription(string medicationCode = "ASPIRIN")
{
var code = MedicationCode.Create(medicationCode).Value;
var dosage = Dosage.Create(100, "mg").Value;
return Prescription.Create(code, dosage, "Once daily").Value;
}
}Testing with In-Memory EF Core
// tests/Application.UnitTests/Infrastructure/PatientRepositoryTests.cs
public class PatientRepositoryTests : IDisposable
{
private readonly AppDbContext _context;
private readonly PatientRepository _sut;
public PatientRepositoryTests()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_context = new AppDbContext(options, new NoOpDomainEventPublisher());
_sut = new PatientRepository(_context);
}
[Fact]
public async Task ExistsByMRN_returns_true_when_patient_exists()
{
// Arrange
var patient = Patient.Create("John Smith", new DateOnly(1985, 3, 15), "MRN-001").Value;
await _context.Patients.AddAsync(patient);
await _context.SaveChangesAsync();
// Act
var exists = await _sut.ExistsByMRNAsync("MRN-001", CancellationToken.None);
// Assert
exists.Should().BeTrue();
}
public void Dispose() => _context.Dispose();
}
// Fakes/NoOpDomainEventPublisher.cs
public sealed class NoOpDomainEventPublisher : IDomainEventPublisher
{
public Task PublishAsync(IEnumerable<IDomainEvent> events, CancellationToken ct)
=> Task.CompletedTask;
}FluentAssertions Patterns
// Result assertions
result.IsSuccess.Should().BeTrue();
result.IsFailure.Should().BeTrue();
result.Value.Should().NotBeNull();
result.Error.Should().Be(PatientErrors.NotFound);
result.Error.Code.Should().StartWith("Patient.");
// Collection assertions
events.Should().ContainSingle();
events.Should().HaveCount(2);
events.Should().Contain(e => e is PatientRegisteredDomainEvent);
// Null assertions
patient.Should().NotBeNull();
patient.BloodType.Should().BeNull();
// Timing (for async operations)
var act = () => _sut.Handle(command, CancellationToken.None);
await act.Should().NotThrowAsync();PRO TIP — xUnit v3 Class Fixtures
In xUnit v3,
IAsyncLifetimefor async setup/teardown is cleaner than constructor initialization for DbContext:
public class HandlerTests : IAsyncLifetime
{
private AppDbContext _context = null!;
private CreatePatientCommandHandler _sut = null!;
public async Task InitializeAsync()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_context = new AppDbContext(options, new NoOpDomainEventPublisher());
await _context.Database.EnsureCreatedAsync();
_sut = new CreatePatientCommandHandler(new PatientRepository(_context), _context);
}
public async Task DisposeAsync()
{
await _context.DisposeAsync();
}
[Fact]
public async Task My_test() { ... }
}Key Takeaway
The best unit tests in Clean Architecture target domain entities and application handlers — both can be instantiated with fakes and no test infrastructure. Domain entities test invariants. Handlers test the orchestration of domain rules, repository queries, and Result returns. FluentAssertions makes failure messages readable by showing what was expected vs what was received. Fast, isolated, and meaningful tests are possible because the architecture kept business logic away from infrastructure.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.