Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20265 min read
Clean Architecture.NETUnit TestingxUnitFluentAssertionsTDD
Share:𝕏

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

XML
<!-- 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:

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

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

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

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

C#
// 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, IAsyncLifetime for async setup/teardown is cleaner than constructor initialization for DbContext:

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

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.