.NET & C# Development · Lesson 86 of 92
Unit Test Your Handlers — Fast, Isolated, Reliable
What Makes a Good Unit Test
A good unit test is:
- Fast — milliseconds, not seconds. No network, no disk, no database
- Isolated — tests one unit of logic in one class
- Deterministic — same result every run, no flakiness
- Readable — failing output tells you exactly what went wrong
The enemy of good unit tests is testing the wrong thing. Testing that EF Core can save to SQL Server is an integration test. Testing that your handler correctly calculates an order total is a unit test.
Setup
dotnet add package xunit
dotnet add package xunit.runner.visualstudio
dotnet add package FluentAssertions
dotnet add package NSubstitute # only needed for complex mocking
dotnet add package Microsoft.NET.Test.SdkxUnit Basics: Fact and Theory
public class OrderTests
{
[Fact]
public void NewOrder_HasPendingStatus()
{
var order = Order.Create(Guid.NewGuid(), new List<OrderLine>());
order.Status.Should().Be(OrderStatus.Pending);
}
[Theory]
[InlineData(1, 10.00, 10.00)]
[InlineData(3, 25.00, 75.00)]
[InlineData(2, 12.50, 25.00)]
public void OrderTotal_IsQuantityTimesUnitPrice(
int quantity, decimal unitPrice, decimal expectedTotal)
{
var line = new OrderLine(Guid.NewGuid(), quantity, unitPrice);
var order = Order.Create(Guid.NewGuid(), new List<OrderLine> { line });
order.Total.Should().Be(expectedTotal);
}
}[Fact] for a single case. [Theory] with [InlineData] for parameterised cases — one test per data row.
Testing a CQRS Handler Directly
No HTTP, no DI container, no database. Just instantiate and call Handle.
The handler under test:
public class GetOrderByIdHandler : IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
private readonly IOrderRepository _repository;
public GetOrderByIdHandler(IOrderRepository repository)
=> _repository = repository;
public async Task<OrderDto?> Handle(
GetOrderByIdQuery request,
CancellationToken ct)
{
var order = await _repository.GetByIdAsync(request.OrderId, ct);
if (order is null)
return null;
return new OrderDto(
order.Id,
order.CustomerId,
order.Status,
order.Total,
order.CreatedAt);
}
}Faking Dependencies Without Moq
For simple dependencies, write a fake class. No mocking library required:
public class FakeOrderRepository : IOrderRepository
{
private readonly List<Order> _orders = new();
public void Seed(params Order[] orders) => _orders.AddRange(orders);
public Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> Task.FromResult(_orders.FirstOrDefault(o => o.Id == id));
public Task<List<Order>> GetByCustomerAsync(Guid customerId, CancellationToken ct = default)
=> Task.FromResult(_orders.Where(o => o.CustomerId == customerId).ToList());
public Task SaveAsync(Order order, CancellationToken ct = default)
{
_orders.Add(order);
return Task.CompletedTask;
}
}The test:
public class GetOrderByIdHandlerTests
{
private readonly FakeOrderRepository _repository = new();
private readonly GetOrderByIdHandler _handler;
public GetOrderByIdHandlerTests()
{
_handler = new GetOrderByIdHandler(_repository);
}
[Fact]
public async Task ReturnsOrderDto_WhenOrderExists()
{
var order = Order.Create(Guid.NewGuid(), new List<OrderLine>());
_repository.Seed(order);
var result = await _handler.Handle(
new GetOrderByIdQuery(order.Id),
CancellationToken.None);
result.Should().NotBeNull();
result!.OrderId.Should().Be(order.Id);
result.Status.Should().Be(OrderStatus.Pending);
}
[Fact]
public async Task ReturnsNull_WhenOrderDoesNotExist()
{
var result = await _handler.Handle(
new GetOrderByIdQuery(Guid.NewGuid()),
CancellationToken.None);
result.Should().BeNull();
}
}When to Use NSubstitute
Use a mocking library when:
- The fake would need significant logic you don't want to maintain
- You need to verify a specific method was called with specific arguments
- The interface has many methods and you only care about one
public class PlaceOrderHandlerTests
{
private readonly IOrderRepository _repository;
private readonly IEventPublisher _eventPublisher;
private readonly PlaceOrderHandler _handler;
public PlaceOrderHandlerTests()
{
_repository = Substitute.For<IOrderRepository>();
_eventPublisher = Substitute.For<IEventPublisher>();
_handler = new PlaceOrderHandler(_repository, _eventPublisher);
}
[Fact]
public async Task PublishesOrderPlacedEvent_AfterSavingOrder()
{
var command = new PlaceOrderCommand(
Guid.NewGuid(),
new List<OrderLineDto> { new(Guid.NewGuid(), 2) });
await _handler.Handle(command, CancellationToken.None);
await _eventPublisher.Received(1)
.PublishAsync(Arg.Any<OrderPlacedEvent>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task SavesOrder_BeforePublishingEvent()
{
var callOrder = new List<string>();
_repository
.When(r => r.SaveAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>()))
.Do(_ => callOrder.Add("save"));
_eventPublisher
.When(e => e.PublishAsync(Arg.Any<OrderPlacedEvent>(), Arg.Any<CancellationToken>()))
.Do(_ => callOrder.Add("publish"));
var command = new PlaceOrderCommand(Guid.NewGuid(), new());
await _handler.Handle(command, CancellationToken.None);
callOrder.Should().Equal("save", "publish");
}
}FluentAssertions for Readable Failures
Compare the failure messages:
// xUnit Assert — failure: "Assert.Equal() Failure. Expected: Active. Actual: Pending"
Assert.Equal(OrderStatus.Active, order.Status);
// FluentAssertions — failure: "Expected order.Status to be Active, but found Pending."
order.Status.Should().Be(OrderStatus.Active);Useful assertions:
// Collections
result.Should().HaveCount(3);
result.Should().Contain(o => o.CustomerId == customerId);
result.Should().BeEmpty();
result.Should().BeInAscendingOrder(o => o.CreatedAt);
// Exceptions
var act = () => order.Cancel();
act.Should().Throw<InvalidOperationException>()
.WithMessage("*already cancelled*");
// Nulls
result.Should().NotBeNull();
result.Should().BeNull();
// Strings
message.Should().StartWith("Order");
message.Should().Contain("confirmed");What NOT to Unit Test
Don't unit test infrastructure glue:
- EF Core saving to a database — that's an integration test
HttpClientcalls to external APIs — mock at the boundary or use integration tests- Controller action methods that just call MediatR — the handler is the logic
- AutoMapper configuration — test it with a real mapping profile, not a mock
Don't unit test trivial code:
- Properties with no logic
- Simple pass-through methods
- Generated code (DTOs, records with no behaviour)
The heuristic: if the test verifies a business rule ("orders with zero items can't be placed"), write it. If it verifies that the framework works ("EF Core can save an entity"), skip it or write an integration test.