FluentAssertions Patterns for Clearer .NET Tests
The built-in Assert class in xUnit gets the job done, but its failure messages are often cryptic: "Assert.Equal() Failure. Expected: 5, Actual: 3." When a test fails, you want to know what failed and why as quickly as possible. FluentAssertions transforms your test assertions into readable statements that produce detailed failure messages.
Note on licensing: FluentAssertions 7+ moved to a commercial licence for non-open-source projects. Version 6.x remains free under the Apache 2.0 licence. If your project is commercial, evaluate the licensing terms before upgrading or consider alternatives like Shouldly.
Installation
dotnet add package FluentAssertions
Basic Assertions
The core pattern is simple: call .Should() on any object, then chain an assertion method.
// Instead of: Assert.Equal("Alice", user.Name);
user.Name.Should().Be("Alice");
// Instead of: Assert.True(user.IsActive);
user.IsActive.Should().BeTrue();
// Instead of: Assert.NotNull(result);
result.Should().NotBeNull();
// Instead of: Assert.InRange(score, 0, 100);
score.Should().BeInRange(0, 100);
When user.Name is "Bob" instead of "Alice", FluentAssertions reports: Expected user.Name to be "Alice", but found "Bob". The variable name appears in the message because FluentAssertions inspects the calling expression.
String Assertions
email.Should().Contain("@");
email.Should().EndWith(".com");
email.Should().MatchRegex(@"^[\w.-]+@[\w.-]+\.\w+$");
email.Should().NotBeNullOrWhiteSpace();
// Case-insensitive comparison
name.Should().BeEquivalentTo("ALICE");
// Wildcard matching
path.Should().Match("/api/users/*/orders");
Collection Assertions
This is where FluentAssertions truly shines. Collection assertions are far more expressive than what's available out of the box:
var users = await _repository.GetActiveUsersAsync();
users.Should().HaveCount(3);
users.Should().NotBeEmpty();
users.Should().ContainSingle(u => u.Role == "Admin");
users.Should().OnlyContain(u => u.IsActive);
users.Should().BeInAscendingOrder(u => u.Name);
// Check that a collection contains specific items
users.Select(u => u.Name)
.Should().Contain(new[] { "Alice", "Bob" })
.And.NotContain("Charlie");
Object Graph Comparison
Comparing complex objects field by field is a chore. BeEquivalentTo does a deep structural comparison:
var expected = new UserDto
{
Name = "Alice",
Email = "[email protected]",
Roles = new[] { "Admin", "Editor" }
};
var actual = _mapper.Map<UserDto>(userEntity);
actual.Should().BeEquivalentTo(expected);
You can exclude specific properties or customise the comparison:
actual.Should().BeEquivalentTo(expected, options => options
.Excluding(u => u.Id)
.Excluding(u => u.CreatedAt)
.WithStrictOrdering()); // Enforce collection ordering
This is particularly useful for testing mappers, DTOs, and API responses where you need to compare shapes without caring about auto-generated fields.
Exception Assertions
var act = () => _service.TransferFunds(fromAccount: "A", toAccount: "B", amount: -100);
act.Should().Throw<ArgumentException>()
.WithMessage("*cannot be negative*")
.WithParameterName("amount");
For async methods:
var act = async () => await _service.GetUserAsync(userId: 0);
await act.Should().ThrowAsync<NotFoundException>()
.WithMessage("User with ID 0 was not found");
DateTime Assertions
var orderDate = order.CreatedAt;
orderDate.Should().BeAfter(DateTime.UtcNow.AddMinutes(-1));
orderDate.Should().BeBefore(DateTime.UtcNow);
orderDate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
BeCloseTo is essential for testing timestamps — it accounts for the small time difference between creating the object and asserting on it.
Assertion Scopes
When a test has multiple assertions, the first failure stops execution by default. AssertionScope collects all failures and reports them together:
[Fact]
public void UserProfile_HasExpectedValues()
{
var profile = _service.GetProfile("alice");
using (new AssertionScope())
{
profile.Name.Should().Be("Alice Smith");
profile.Email.Should().Contain("@");
profile.Roles.Should().Contain("Admin");
profile.IsVerified.Should().BeTrue();
}
}
If both Name and IsVerified are wrong, you see both failures in a single test run rather than having to fix them one at a time.
Custom Failure Messages
Add context to assertions with Because:
user.Should().NotBeNull("because the repository should always return a default user");
balance.Should().BePositive("because overdrafts are not permitted for this account type");
The because text appears in the failure message, making it clear why the assertion matters.
Practical Advice
- Use
BeEquivalentTofor DTOs and response objects — it saves dozens of individual assertions. - Always use
AssertionScopewhen testing multiple properties of the same object. - Prefer specific assertions (
BeEmpty,ContainSingle) over generic ones (BeTrue,BeFalse) — the failure messages are far more helpful. - Add
becausereasons when the intent of an assertion isn't obvious from the code.
FluentAssertions is one of those libraries that, once you've used it, makes going back to basic assertions feel like a step backwards. The readability gains are significant, and the failure messages alone justify the dependency.