Q24 — Unit Testing with xUnit, NSubstitute, FluentAssertions — Full Examples
Level: Mid-Level | Topic: Testing
The Testing Pyramid
A healthy test suite follows the pyramid: ~70% unit tests (milliseconds, no I/O, xUnit + NSubstitute + FluentAssertions), ~20% integration tests (seconds, real DB via Testcontainers), ~10% E2E/contract tests (minutes, full stack). Most bugs are caught cheapest at the unit level.
The AAA Pattern
Every test follows Arrange → Act → Assert. NSubstitute creates fakes of interfaces. FluentAssertions provides readable failure messages. [Theory] + [InlineData] parameterises tests for multiple inputs without duplication.
Code Example
// Packages: xunit, NSubstitute, FluentAssertions
public class OrderServiceTests
{
// Shared fakes — recreated per test (xUnit creates a new instance per [Fact])
private readonly IOrderRepository _repo = Substitute.For<IOrderRepository>();
private readonly IEventPublisher _events = Substitute.For<IEventPublisher>();
private readonly OrderService _sut;
public OrderServiceTests()
=> _sut = new OrderService(_repo, _events, NullLogger<OrderService>.Instance);
// ── Happy path ──────────────────────────────────────────────
[Fact]
public async Task CreateAsync_WithValidRequest_ShouldSaveAndPublishEvent()
{
// Arrange
var request = new CreateOrderRequest("cust-123", 99.99m);
_repo.SaveAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
// Act
var result = await _sut.CreateAsync(request, CancellationToken.None);
// Assert — FluentAssertions: readable failure messages
result.Should().NotBeNull();
result.CustomerId.Should().Be("cust-123");
result.Total.Should().Be(99.99m);
result.Status.Should().Be("Pending");
// Verify interactions — was SaveAsync called exactly once?
await _repo.Received(1).SaveAsync(
Arg.Is<Order>(o => o.CustomerId == "cust-123"),
Arg.Any<CancellationToken>());
await _events.Received(1).PublishAsync(
Arg.Is<OrderCreatedEvent>(e => e.CustomerId == "cust-123"),
Arg.Any<CancellationToken>());
}
// ── Parameterised validation tests ─────────────────────────
[Theory]
[InlineData("", 99.99)]
[InlineData(null, 99.99)]
[InlineData("cust-1", 0)]
[InlineData("cust-1", -1)]
public async Task CreateAsync_WithInvalidInput_ShouldThrowValidationException(
string? customerId, decimal total)
{
var request = new CreateOrderRequest(customerId!, total);
await _sut.Invoking(s => s.CreateAsync(request, CancellationToken.None))
.Should().ThrowAsync<ValidationException>();
// Nothing should be saved on invalid input
await _repo.DidNotReceive().SaveAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>());
}
// ── Infrastructure failure doesn't publish event ───────────
[Fact]
public async Task CreateAsync_WhenRepositoryThrows_ShouldNotPublishEvent()
{
var request = new CreateOrderRequest("cust-1", 50m);
_repo.SaveAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new Exception("DB connection failed"));
var act = async () => await _sut.CreateAsync(request, CancellationToken.None);
await act.Should().ThrowAsync<Exception>();
await _events.DidNotReceive()
.PublishAsync(Arg.Any<OrderCreatedEvent>(), Arg.Any<CancellationToken>());
}
}
Senior Insight
Name tests with the pattern MethodName_Condition_ExpectedOutcome — it reads as living documentation. Use NullLogger<T>.Instance rather than mocking ILogger — you rarely need to assert on log calls, and the mock noise obscures test intent. Prefer Arg.Is<T>(predicate) over Arg.Any<T>() in verification calls — it catches wrong data being passed to dependencies. Keep each test focused on one behaviour.