FluentAssertions: Write Expressive C# Test Assertions

FluentAssertions: Write Expressive C# Test Assertions

Unit tests fail for two reasons: the code is wrong, or the assertion message is so cryptic you spend 20 minutes figuring out what it even checked. FluentAssertions solves the second problem completely.

FluentAssertions is a .NET library that replaces Assert.AreEqual(expected, actual) with a fluent, English-like API. Instead of Assert.IsTrue(list.Count == 3), you write list.Should().HaveCount(3). When it fails, you get a message that actually tells you what happened.

This guide covers the essential FluentAssertions patterns every C# developer should know.

Installation

dotnet add package FluentAssertions

Add the using directive to your test files:

using FluentAssertions;

That's it. FluentAssertions works with NUnit, xUnit, MSTest, and any other test framework.

Basic Value Assertions

The entry point to every assertion is .Should():

int result = Calculator.Add(2, 3);

result.Should().Be(5);
result.Should().BePositive();
result.Should().BeGreaterThan(4);
result.Should().BeInRange(1, 10);

For strings:

string name = user.FullName;

name.Should().Be("Jane Doe");
name.Should().StartWith("Jane");
name.Should().EndWith("Doe");
name.Should().Contain("Doe");
name.Should().HaveLength(8);
name.Should().NotBeNullOrEmpty();
name.Should().MatchRegex(@"^[A-Z][a-z]+ [A-Z][a-z]+$");

String comparisons are case-sensitive by default. For case-insensitive:

name.Should().BeEquivalentTo("jane doe");

Boolean Assertions

bool isValid = validator.Validate(email);

isValid.Should().BeTrue();
isValid.Should().BeFalse();

More readable than Assert.IsTrue(isValid) — especially when the variable name is less obvious than isValid.

Collection Assertions

FluentAssertions shines with collections:

var items = repository.GetAll();

items.Should().NotBeEmpty();
items.Should().HaveCount(3);
items.Should().HaveCountGreaterThan(0);
items.Should().Contain(x => x.Id == 42);
items.Should().NotContain(x => x.IsDeleted);
items.Should().OnlyContain(x => x.IsActive);
items.Should().BeInAscendingOrder(x => x.Name);
items.Should().BeEquivalentTo(expectedItems);

The BeEquivalentTo assertion does deep structural comparison — it checks that both collections contain the same elements regardless of order:

var expected = new[] { new { Id = 1, Name = "Alice" }, new { Id = 2, Name = "Bob" } };
var actual = repository.GetUsers();

actual.Should().BeEquivalentTo(expected, options => options
    .Including(x => x.Id)
    .Including(x => x.Name));

Object Equivalency

Deep object comparison is where FluentAssertions really earns its place:

var expected = new Order
{
    Id = 1,
    CustomerName = "Alice",
    Total = 99.99m
};

var actual = orderService.CreateOrder(request);

actual.Should().BeEquivalentTo(expected, options => options
    .Excluding(x => x.CreatedAt)  // ignore timestamp
    .Excluding(x => x.Id));       // ignore auto-generated id

Without FluentAssertions, this requires either a custom equality comparer or a series of individual property assertions. With it, one line does the comparison and the failure message tells you exactly which property mismatched.

Null Checking

User user = repository.FindById(999);

user.Should().BeNull();
user.Should().NotBeNull();

// With a reason (appears in failure message)
user.Should().NotBeNull("because user 999 was seeded in the test setup");

The because parameter is one of FluentAssertions' most useful features. It embeds your reasoning into the failure message, so when a test breaks two weeks later, you understand why the assertion exists.

Exception Assertions

Exception testing in vanilla NUnit requires Assert.Throws<T>() or [ExpectedException]. FluentAssertions makes it more expressive:

// Basic exception check
Action act = () => service.ProcessOrder(null);
act.Should().Throw<ArgumentNullException>();

// Check exception message
act.Should().Throw<InvalidOperationException>()
    .WithMessage("Order cannot be empty*");  // * is wildcard

// Check inner exception
act.Should().Throw<ApplicationException>()
    .WithInnerException<DatabaseException>();

// Check no exception is thrown
Action safeAct = () => service.ProcessOrder(validOrder);
safeAct.Should().NotThrow();

For async methods:

Func<Task> act = async () => await service.SaveAsync(null);
await act.Should().ThrowAsync<ArgumentNullException>();
await act.Should().ThrowAsync<ArgumentNullException>()
    .WithMessage("*cannot be null*");

Numeric and DateTime Assertions

decimal price = product.CalculatePrice();

price.Should().BeApproximately(9.99m, precision: 0.01m);
price.Should().BeGreaterThanOrEqualTo(0);

DateTime createdAt = entity.CreatedAt;

createdAt.Should().BeAfter(DateTime.UtcNow.AddMinutes(-1));
createdAt.Should().BeBefore(DateTime.UtcNow.AddMinutes(1));
createdAt.Should().BeCloseTo(DateTime.UtcNow, precision: TimeSpan.FromSeconds(5));

Chaining Assertions

You can chain multiple assertions on the same subject:

string result = service.FormatName("  john doe  ");

result.Should()
    .NotBeNull()
    .And.NotBeEmpty()
    .And.Be("John Doe")
    .And.HaveLength(8);

Chaining is syntactic sugar — each .And just returns the same assertion object.

Custom Assertion Messages

Every assertion accepts an optional reason:

items.Should().HaveCount(3, "because we seeded exactly 3 items in TestSetup");
result.Should().Be(expected, "because the cache should return the stored value");

This is particularly valuable for non-obvious assertions. If someone changes the seed data and the test fails, they'll see your reason in the failure message.

Assertion Scopes

By default, FluentAssertions stops at the first failure. To collect all failures and report them together:

using (new AssertionScope())
{
    user.Name.Should().Be("Alice");
    user.Email.Should().Contain("@");
    user.Age.Should().BeGreaterThan(18);
}

All three assertions run. If two fail, the exception lists both failures. This is useful when validating multiple properties of a response object — you see all the problems at once instead of fixing them one at a time.

Type Assertions

object result = factory.Create("payment");

result.Should().BeOfType<PaymentProcessor>();
result.Should().BeAssignableTo<IProcessor>();
result.Should().NotBeOfType<LegacyProcessor>();

Practical Example: Testing an API Response

Here's a realistic test that uses several FluentAssertions features together:

[Test]
public async Task CreateUser_WithValidData_ReturnsCreatedUser()
{
    // Arrange
    var request = new CreateUserRequest
    {
        Name = "Alice",
        Email = "alice@example.com"
    };

    // Act
    var response = await _client.PostAsJsonAsync("/api/users", request);
    var user = await response.Content.ReadFromJsonAsync<UserDto>();

    // Assert
    response.StatusCode.Should().Be(HttpStatusCode.Created,
        "because a new user was successfully created");

    user.Should().NotBeNull();
    user!.Should().BeEquivalentTo(request, options => options
        .Including(x => x.Name)
        .Including(x => x.Email));

    user.Id.Should().BePositive("because the database assigned an auto-increment ID");
    user.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
}

Compare this to the same test using NUnit's built-in assertions — the intent is immediately clear, and if it fails, you'll know exactly what went wrong.

When to Use FluentAssertions vs Built-in Assertions

FluentAssertions doesn't replace your test framework — it replaces the assertion layer. You still use NUnit/xUnit/MSTest for test discovery, setup, and teardown. FluentAssertions handles the Should().Be() part.

Use it for:

  • Any test where failure message clarity matters (which is every test)
  • Complex object comparisons that would otherwise require custom equality comparers
  • Exception testing in async code
  • Collection assertions where Contains with a predicate is cleaner than Assert.IsTrue(list.Any(...))

The learning curve is minimal — most assertions are self-explanatory. The payoff is tests that read like specifications and fail with messages that explain themselves.


If you're testing .NET applications and want end-to-end visibility beyond unit tests — monitoring, browser testing, and CI integration — HelpMeTest covers the full stack with usage-based pricing starting at $0.003/run.

Read more

Start now free