FluentAssertions — Readable Assertions for Every Type

FluentAssertions transforms test failure messages from cryptic “Expected: True but was: False” to precise, human-readable explanations: “Expected result.Slug to be ‘my-post’ with a length of 7, but found ‘My Post’ with a length of 7.” This dramatically reduces debugging time — you know exactly what was wrong without re-running with a debugger. FluentAssertions also supports complex assertion scenarios: collection assertions, exception assertions, and approximate equality for floating-point values.

FluentAssertions Patterns

// ── String assertions ──────────────────────────────────────────────────────
result.Slug.Should().Be("getting-started");
result.Title.Should().Contain(".NET");
result.Title.Should().StartWith("Getting");
result.Slug.Should().MatchRegex(@"^[a-z0-9\-]+$");
result.Excerpt.Should().NotBeNullOrWhiteSpace();
result.Body.Should().HaveLength(500);

// Reason parameter — explains WHY in failure messages
result.Status.Should().Be("published", because:
    "a post submitted for review must be published after approval");

// ── Numeric assertions ────────────────────────────────────────────────────
result.ViewCount.Should().BeGreaterThanOrEqualTo(0);
result.CommentCount.Should().BeInRange(0, 10_000);
result.ReadingTime.Should().BeApproximately(5, precision: 1);  // 5 ± 1 minutes

// ── Collection assertions ─────────────────────────────────────────────────
var posts = await _sut.GetPublishedAsync(/* ... */);

posts.Should().NotBeEmpty();
posts.Should().HaveCount(10);
posts.Should().AllSatisfy(p => p.IsPublished.Should().BeTrue());
posts.Should().BeInDescendingOrder(p => p.PublishedAt);
posts.Should().Contain(p => p.Slug == "target-slug");
posts.Should().ContainSingle(p => p.IsFeatured);  // exactly one featured

// Deep equality — compares all matching properties
var expected = new PostDto(Id: 1, Title: "Post", Slug: "post",
                           Status: "published");
result.Should().BeEquivalentTo(expected, opts =>
    opts.Excluding(p => p.PublishedAt)     // exclude timestamp (unpredictable)
        .Excluding(p => p.RowVersion));     // exclude EF Core rowversion

// ── Exception assertions ──────────────────────────────────────────────────
// Synchronous exceptions:
Action act = () => _sut.ValidateSlug("");
act.Should().Throw<ArgumentException>()
   .WithMessage("*empty*")
   .And.ParamName.Should().Be("slug");

// Async exceptions:
Func<Task> asyncAct = () => _sut.CreateAsync(duplicateRequest, default);
await asyncAct.Should().ThrowAsync<ConflictException>()
    .WithMessage("*already exists*");

// No exception thrown:
Func<Task> successAct = () => _sut.CreateAsync(validRequest, default);
await successAct.Should().NotThrowAsync();

// ── Object assertions ─────────────────────────────────────────────────────
result.Should().NotBeNull();
result.Should().BeOfType<PostDto>();
result.Should().BeAssignableTo<IPost>();

// ── Custom assertion extension ─────────────────────────────────────────────
// Create reusable assertions for domain objects:
public static class PostAssertionExtensions
{
    public static void BePublished(this ObjectAssertions assertions)
    {
        var post = (PostDto)assertions.Subject;
        post.Status.Should().Be("published");
        post.IsPublished.Should().BeTrue();
        post.PublishedAt.Should().NotBeNull();
    }
}
// Usage: result.Should().BePublished();
Note: The because parameter in FluentAssertions (.Should().Be(x, because: "reason")) is included in failure messages and serves as in-code documentation of why the assertion must hold. This is particularly valuable for non-obvious business rules: .Should().BeTrue(because: "only admins can delete other users' posts"). When the test fails, the reason appears in the output, making it immediately clear what business rule was violated — no need to read surrounding test code.
Tip: Use BeEquivalentTo() with Excluding() for DTO comparisons instead of individually asserting each property. BeEquivalentTo performs deep recursive comparison of all properties and gives a detailed diff showing exactly which property mismatched. The Excluding() option removes unpredictable fields (timestamps, rowversions, generated IDs) from the comparison. This is far more effective than 10 individual property.Should().Be(expected) assertions which only show one mismatch at a time.
Warning: Do not chain multiple independent assertions on the same object when you want to see all failures at once. Standard FluentAssertions stops at the first failure. Use AssertionScope to collect all failures and report them together: using (new AssertionScope()) { result.Title.Should().Be("x"); result.Status.Should().Be("y"); } — both failures are reported even if the first assertion fails. This is especially useful for DTO validation where you want to see all mismatched fields in one run.

Common Mistakes

Mistake 1 — Assert.Equal instead of .Should().Be() for complex types (unhelpful failure message)

❌ Wrong — Assert.Equal(expected, result); failure: “Expected: PostDto, Actual: PostDto” — identical string representations.

✅ Correct — result.Should().BeEquivalentTo(expected); failure shows exact property diff.

Mistake 2 — No AssertionScope for multiple assertions (only first failure reported)

❌ Wrong — 5 assertions on the same object; first fails; other 4 not checked; fix one thing, re-run, fix another, etc.

✅ Correct — using (new AssertionScope()) { ... }; all 5 failures reported in one run.

🧠 Test Yourself

A test uses posts.Should().BeInDescendingOrder(p => p.PublishedAt). The list has 3 posts with dates: 2024-07-15, 2024-06-01, 2024-07-10. Does the assertion pass?