Learnixo
Back to blog
AI Systemsintermediate

FluentAssertions — Readable Assertions and Error Messages

FluentAssertions v7 in .NET: collection assertions, object graph comparison, exception assertions, custom assertion messages, and the patterns that make test failures self-explanatory.

Asma Hafeez KhanMay 16, 20264 min read
TestingFluentAssertions.NETxUnitAssertions
Share:𝕏

Why FluentAssertions

xUnit's built-in Assert.Equal(expected, actual) puts expected first — a convention easy to forget. The failure message Expected: True, Actual: False is opaque. FluentAssertions produces failure messages that read like sentences.

C#
// xUnit Assert
Assert.Equal("MRN-001", patient.MRN);
// Failure: Assert.Equal() Failure
// Expected: MRN-001
// Actual:   MRN-002

// FluentAssertions
patient.MRN.Should().Be("MRN-001");
// Failure: Expected patient.MRN to be "MRN-001", but found "MRN-002".

Basic Assertions

C#
// Equality
result.Value.Should().Be(expectedId);
patient.Name.Should().Be("John Smith");
patient.Name.Should().NotBe("Jane Doe");

// Null
patient.Should().NotBeNull();
prescription.AllergyWarning.Should().BeNull();

// Boolean
result.IsSuccess.Should().BeTrue();
patient.IsActive.Should().BeTrue("because we just created the patient");

// Numeric
dosage.Amount.Should().Be(5m);
dosage.Amount.Should().BeGreaterThan(0m);
dosage.Amount.Should().BeInRange(0.1m, 1000m);

// String
patient.MRN.Should().StartWith("MRN-");
patient.Email.Should().Contain("@");
patient.Name.Should().NotBeNullOrWhiteSpace();
patient.Name.Should().HaveLength(10);
patient.Name.Should().MatchRegex(@"^[A-Za-z\s]+$");

// DateTime
patient.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
prescription.ExpiresAt.Should().BeAfter(DateTime.UtcNow);

Collection Assertions

C#
var prescriptions = patient.Prescriptions;

// Count
prescriptions.Should().HaveCount(3);
prescriptions.Should().HaveCountGreaterThan(0);
prescriptions.Should().NotBeEmpty();
prescriptions.Should().BeEmpty();

// Content
prescriptions.Should().Contain(rx => rx.DrugName == "Warfarin");
prescriptions.Should().NotContain(rx => rx.IsExpired);
prescriptions.Should().AllSatisfy(rx => rx.IsActive.Should().BeTrue());
prescriptions.Should().ContainSingle(rx => rx.DrugName == "Aspirin");

// Equivalence
prescriptions.Should().BeEquivalentTo(expected);  // deep comparison, order-independent
prescriptions.Should().Equal(expected);            // order-sensitive, reference equality

// Specific elements
prescriptions.Should().HaveElementAt(0, firstExpected);
prescriptions.First().DrugName.Should().Be("Warfarin");

// Ordering
prescriptions.Should().BeInAscendingOrder(rx => rx.IssuedAt);
prescriptions.Should().BeInDescendingOrder(rx => rx.Priority);

Object Graph Comparison

C#
// BeEquivalentTo compares all public properties by value
var expected = new PatientDto(
    Id:         patientId,
    Name:       "John Smith",
    MRN:        "MRN-001",
    Department: "Cardiology");

actual.Should().BeEquivalentTo(expected);

// Exclude properties from comparison
actual.Should().BeEquivalentTo(expected, options =>
    options.Excluding(p => p.CreatedAt)   // timestamps differ — exclude
           .Excluding(p => p.Id));         // generated ID — exclude

// Compare only specific properties
actual.Should().BeEquivalentTo(expected, options =>
    options.Including(p => p.Name)
           .Including(p => p.MRN));

// Custom comparison for nested objects
actual.Should().BeEquivalentTo(expected, options =>
    options.Using<DateTime>(ctx =>
        ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1)))
    .WhenTypeIs<DateTime>());

Exception Assertions

C#
// Assert an exception is thrown
Action act = () => Patient.Create(null!, new DateOnly(1985, 3, 15), "MRN-001");
act.Should().Throw<ArgumentNullException>()
    .WithMessage("*name*");

// Async exceptions
Func<Task> act = async () =>
    await _handler.Handle(invalidCommand, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>()
    .WithMessage("*MRN*");

// Assert NO exception (explicit)
Action act = () => Dosage.Create(5m, "mg");
act.Should().NotThrow();

// Assert specific exception properties
act.Should().Throw<DomainException>()
    .Which.ErrorCode.Should().Be("Patient.NotFound");

Result Pattern Assertions

C#
// Custom helper extension for the Result pattern
public static class ResultAssertions
{
    public static void ShouldSucceed<T>(this Result<T> result)
    {
        result.IsSuccess.Should().BeTrue(
            $"expected success but got failure: {result.Error?.Description}");
    }

    public static void ShouldFailWith(this Result result, string errorCode)
    {
        result.IsFailure.Should().BeTrue("expected failure but got success");
        result.Error.Code.Should().Be(errorCode,
            $"expected error code '{errorCode}' but got '{result.Error.Code}'");
    }
}

// Usage
result.ShouldSucceed();
result.ShouldFailWith("Patient.MRNAlreadyExists");

Custom Failure Messages

C#
// Add context to failure messages with "because" parameter
patient.IsActive.Should().BeTrue("because we just created an active patient");
result.IsSuccess.Should().BeTrue(
    $"because command was valid but handler returned: {result.Error?.Description}");

// Multiple assertions — use AssertionScope for batch assertions
// Without scope: first failure stops the test
// With scope: ALL failures reported at once
using (new AssertionScope())
{
    patient.Name.Should().Be("John Smith");
    patient.MRN.Should().Be("MRN-001");
    patient.IsActive.Should().BeTrue();
    patient.Department.Should().Be("Cardiology");
}
// If 3 of these fail, all 3 failures are reported in one test run

Assertion Scopes for Multiple Assertions

C#
[Fact]
public void Patient_created_with_correct_defaults()
{
    var result = Patient.Create("John Smith", new DateOnly(1985, 3, 15), "MRN-001");

    result.IsSuccess.Should().BeTrue();
    var patient = result.Value;

    // All 5 assertions evaluated — all failures reported together
    using (new AssertionScope())
    {
        patient.Name.Should().Be("John Smith");
        patient.MRN.Should().Be("MRN-001");
        patient.IsActive.Should().BeTrue();
        patient.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
        patient.Prescriptions.Should().BeEmpty();
    }
}

Production issue I've seen: A team's test suite had 60% test coverage but tests only asserted one thing per test. When a bug caused 4 properties to be wrong simultaneously, only the first assertion failed — the other 3 were never checked. With AssertionScope, all 4 failures appear in one run, making the root cause obvious immediately.


Type Assertions

C#
// Type checking
result.Value.Should().BeOfType<PatientDto>();
result.Value.Should().BeAssignableTo<IEntity>();
result.Error.Should().BeOfType<ValidationError>();

// Cast and continue
var dto = result.Value.Should().BeOfType<PatientDto>().Which;
dto.Name.Should().Be("John Smith");

Red Flag / Green Answer

Red Flag: "Our test says Assert.True(result.IsSuccess) and when it fails, the output is Expected True but was False."

Zero diagnostic information. You do not know which command was sent, what error was returned, or what state the system was in. FluentAssertions with a "because" clause tells you exactly why the assertion failed.

Green Answer:

result.IsSuccess.Should().BeTrue($"because the command was valid but handler returned: {result.Error?.Code} — {result.Error?.Description}"). Failure message is self-explanatory.


Key Takeaway

FluentAssertions produces readable failure messages that diagnose themselves. Use BeEquivalentTo for deep object comparison with property exclusions. Use AssertionScope to report all assertion failures in one test run — not just the first. Add "because" context to assertions for complex conditions. Custom extension methods for domain patterns (Result, Error) make tests read like requirements.

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.