FluentAssertions: Write .NET Tests That Read Like English

FluentAssertions: Write .NET Tests That Read Like English

FluentAssertions is a .NET assertion library that replaces cryptic Assert.AreEqual() calls with readable, chainable English-like syntax — making test failures self-explanatory and test code easier to maintain.

Key Takeaways

  • FluentAssertions replaces Assert.* with a fluent .Should().Be() chain that reads like a sentence
  • Failure messages are dramatically more informative than stock xUnit/NUnit/MSTest messages
  • AssertionScope lets you batch multiple assertions and see all failures at once instead of stopping at the first
  • Collection assertions like BeEquivalentTo() do deep structural comparison without manual loops
  • Works with xUnit, NUnit, and MSTest — drop it into any existing project with zero configuration

When a test fails at 2 AM and your CI log says Expected: 42 — Actual: 43, you know something is wrong but not what was wrong or why it mattered. FluentAssertions is a .NET assertion library that solves this by replacing the stock assertion APIs with a fluent, chainable syntax that produces human-readable failure messages and makes test intent obvious to anyone reading the code.

This post walks through the core features: basic assertions, collection assertions, exception testing, assertion scopes, and integration with all major .NET test frameworks.

Why FluentAssertions Exists

Standard test framework assertions have two problems. First, the API is inconsistent — Assert.AreEqual(expected, actual) in MSTest, Assert.Equal(expected, actual) in xUnit, Assert.That(actual, Is.EqualTo(expected)) in NUnit. Second, failure messages are minimal. When Assert.AreEqual("John", user.Name) fails you get "Expected: John — Actual: null" with no context about which test, which object, or what state led to the failure.

FluentAssertions flips this. Every assertion reads left-to-right in natural language, and every failure message tells you exactly what was being checked.

// Install via NuGet
// dotnet add package FluentAssertions

using FluentAssertions;

// Before
Assert.Equal("John", user.Name);

// After
user.Name.Should().Be("John");

The Should() extension method is the entry point. It works on any object and returns a typed assertion object whose methods match the type — strings get Contain(), collections get HaveCount(), numbers get BeGreaterThan().

Basic Value Assertions

The most common assertions cover equality, nullability, and type checking.

// Equality
int result = calculator.Add(2, 3);
result.Should().Be(5);
result.Should().NotBe(0);

// Nullability
string name = user.GetDisplayName();
name.Should().NotBeNull();
name.Should().NotBeNullOrEmpty();
name.Should().NotBeNullOrWhiteSpace();

// Type checking
object response = GetApiResponse();
response.Should().BeOfType<SuccessResponse>();
response.Should().BeAssignableTo<IApiResponse>();

// Numeric ranges
decimal price = product.CalculatePrice();
price.Should().BePositive();
price.Should().BeInRange(10.00m, 99.99m);
price.Should().BeGreaterThan(0);
price.Should().BeLessThanOrEqualTo(100m);

// Boolean
bool isValid = validator.Validate(input);
isValid.Should().BeTrue();
isValid.Should().BeFalse("because the input was empty");

The optional because parameter in most assertion methods lets you attach a human-readable reason that appears in failure messages. isValid.Should().BeFalse("because the input was empty") will produce: Expected isValid to be false because the input was empty, but found true.

String Assertions

Strings get a rich set of assertions beyond simple equality.

string email = "user@example.com";

email.Should().StartWith("user");
email.Should().EndWith(".com");
email.Should().Contain("@");
email.Should().HaveLength(16);
email.Should().MatchRegex(@"^[\w.-]+@[\w.-]+\.\w+$");
email.Should().BeEquivalentTo("USER@EXAMPLE.COM"); // case-insensitive

string description = GetProductDescription();
description.Should().NotBeEmpty();
description.Should().HaveLengthLessThanOrEqualTo(500);

Collection Assertions

Collection assertions are where FluentAssertions pulls ahead the most. Deep structural comparison is built in.

var users = userRepository.GetActiveUsers();

// Count and emptiness
users.Should().HaveCount(3);
users.Should().NotBeEmpty();
users.Should().HaveCountGreaterThan(0);

// Containment
users.Should().Contain(u => u.Email == "alice@example.com");
users.Should().OnlyContain(u => u.IsActive);
users.Should().NotContain(u => u.IsDeleted);

// All/Any
users.Should().AllSatisfy(u => u.Name.Should().NotBeNullOrEmpty());
users.Should().ContainSingle(u => u.Role == "Admin");

// Order
var ids = users.Select(u => u.Id);
ids.Should().BeInAscendingOrder();
ids.Should().BeInDescendingOrder();

// Deep structural equality — compares all properties recursively
var expected = new List<User>
{
    new User { Id = 1, Name = "Alice", Email = "alice@example.com" },
    new User { Id = 2, Name = "Bob",   Email = "bob@example.com" }
};

users.Should().BeEquivalentTo(expected);

// BeEquivalentTo with options — ignore specific fields
users.Should().BeEquivalentTo(expected, options =>
    options.Excluding(u => u.CreatedAt)
           .Excluding(u => u.UpdatedAt));

BeEquivalentTo() is particularly powerful. It does recursive property-by-property comparison, handles collections of complex objects, and its failure message tells you exactly which property differed and on which item.

Exception Assertions

Testing that code throws the right exception is much cleaner with FluentAssertions.

// Basic exception assertion
Action act = () => orderService.PlaceOrder(null);
act.Should().Throw<ArgumentNullException>();

// Assert exception message
act.Should().Throw<ArgumentNullException>()
   .WithMessage("*order*"); // supports wildcards

// Assert inner exception
act.Should().Throw<InvalidOperationException>()
   .WithInnerException<DatabaseException>();

// Assert specific property on the exception
act.Should().Throw<ValidationException>()
   .Which.Errors.Should().HaveCount(3);

// Assert no exception is thrown
Action validAct = () => orderService.PlaceOrder(validOrder);
validAct.Should().NotThrow();

// Async exceptions
Func<Task> asyncAct = async () => await orderService.PlaceOrderAsync(null);
await asyncAct.Should().ThrowAsync<ArgumentNullException>();
await asyncAct.Should().ThrowAsync<ArgumentNullException>()
              .WithMessage("Value cannot be null*");

The .Which property after Throw<T>() gives you the caught exception strongly typed, so you can continue asserting against its properties.

Object Graph Assertions with BeEquivalentTo

BeEquivalentTo() deserves its own section because it replaces a large class of manual assertion loops.

public class OrderDto
{
    public int Id { get; set; }
    public string CustomerName { get; set; }
    public List<LineItemDto> Items { get; set; }
    public decimal Total { get; set; }
    public DateTime CreatedAt { get; set; }
}

// Test that mapping produces the correct output
var order = orderService.CreateOrder(request);
var dto = mapper.MapToDto(order);

dto.Should().BeEquivalentTo(new OrderDto
{
    Id = order.Id,
    CustomerName = "Alice Smith",
    Items = new List<LineItemDto>
    {
        new LineItemDto { ProductId = 42, Quantity = 2, Price = 9.99m }
    },
    Total = 19.98m
}, options => options
    .Excluding(o => o.CreatedAt)          // ignore timestamp
    .Using<decimal>(ctx => ctx.Subject    // custom decimal comparison
        .Should().BeApproximately(ctx.Expectation, 0.01m))
    .WhenTypeIs<decimal>());

AssertionScope: See All Failures at Once

By default, the first failing assertion throws an exception and the test stops. When you're diagnosing a complex failure, you want to see all failures in one run. AssertionScope makes this possible.

using FluentAssertions.Execution;

[Fact]
public void Order_should_be_complete()
{
    var order = orderService.GetOrder(123);

    using (new AssertionScope())
    {
        order.Should().NotBeNull();
        order.Status.Should().Be(OrderStatus.Complete);
        order.Items.Should().HaveCount(3);
        order.Total.Should().BeGreaterThan(0);
        order.ShippingAddress.Should().NotBeNull();
        order.CustomerEmail.Should().Contain("@");
    }
    // If any assertions fail, ALL failures are reported together
}

Without AssertionScope, if order is null, the test stops there and you never know whether Status, Items, and Total were also wrong. With the scope, you get a complete picture in a single test run.

Chaining Assertions

Multiple assertions on the same subject can be chained to reduce noise.

// Without chaining
user.Name.Should().NotBeNull();
user.Name.Should().StartWith("A");
user.Name.Should().HaveLengthLessThan(50);

// With chaining (reads as a single sentence)
user.Name.Should().NotBeNull()
         .And.StartWith("A")
         .And.HaveLengthLessThan(50);

// Collections
var result = search.Execute("dotnet");
result.Should().NotBeNull()
      .And.NotBeEmpty()
      .And.HaveCountLessThanOrEqualTo(10)
      .And.OnlyContain(r => r.Score > 0);

Integration with xUnit, NUnit, and MSTest

FluentAssertions detects which test framework is active and integrates automatically. You don't configure anything — just install the package and it works.

// xUnit
public class UserServiceTests
{
    [Fact]
    public void CreateUser_with_valid_data_should_return_user()
    {
        var service = new UserService();
        var user = service.Create("Alice", "alice@example.com");
        user.Should().NotBeNull();
        user.Name.Should().Be("Alice");
    }
}

// NUnit
[TestFixture]
public class UserServiceTests
{
    [Test]
    public void CreateUser_with_valid_data_should_return_user()
    {
        var service = new UserService();
        var user = service.Create("Alice", "alice@example.com");
        user.Should().NotBeNull();
        user.Name.Should().Be("Alice");
    }
}

// MSTest
[TestClass]
public class UserServiceTests
{
    [TestMethod]
    public void CreateUser_with_valid_data_should_return_user()
    {
        var service = new UserService();
        var user = service.Create("Alice", "alice@example.com");
        user.Should().NotBeNull();
        user.Name.Should().Be("Alice");
    }
}

The Should() extension method is the same in all three. Failure messages are routed to the correct exception type for each framework, so CI reporters and IDE test runners show results correctly.

Custom Assertion Scopes and Extension Methods

For domain-specific assertions, you can write extension methods on top of FluentAssertions.

public static class OrderAssertionExtensions
{
    public static AndConstraint<ObjectAssertions> BeCompleted(
        this ObjectAssertions assertions)
    {
        var order = assertions.Subject as Order;
        Execute.Assertion
            .BecauseOf("", new object[0])
            .ForCondition(order != null && order.Status == OrderStatus.Complete)
            .FailWith("Expected order to be completed, but status was {0}",
                order?.Status);

        return new AndConstraint<ObjectAssertions>(assertions);
    }
}

// Usage
order.Should().BeCompleted();
order.Should().BeCompleted().And.Subject.As<Order>().Items.Should().HaveCount(3);

Practical Tips

A few patterns that improve test quality when using FluentAssertions:

Always add a because reason to non-obvious assertions. result.Should().BeNull("because the user does not have permission to see deleted records") — when this fails six months from now, the reason is right there.

Prefer BeEquivalentTo() over property-by-property assertions for complex objects. It is more concise and less likely to miss a field when the object grows.

Use AssertionScope in arrange-heavy tests where setting up a broken state is expensive and you want to see all failures at once rather than re-running repeatedly.

Avoid chaining more than three or four clauses. Chains are readable up to a point; after that, separate assertions are clearer.

Installing FluentAssertions

dotnet add package FluentAssertions

That's it. No configuration file, no test framework adapter, no setup. The Should() extension method becomes available on every object as soon as you add a using FluentAssertions; statement.

FluentAssertions supports .NET Standard 2.0, .NET Framework 4.7, and all modern .NET versions. The current major version (6.x) introduced a stricter license for commercial use; if that matters to your project, check the release notes and consider pinning to 5.x which remains MIT-licensed.

Conclusion

FluentAssertions is one of the highest-value additions you can make to a .NET test suite. The installation takes thirty seconds, the learning curve is minimal, and the payoff — readable tests, informative failure messages, and the ability to see all failures at once with AssertionScope — shows up on the first test run.

The fluent API enforces a consistent assertion style across a team, makes PR reviews faster because the test intent is obvious, and makes debugging faster because failure messages tell you what was wrong rather than just that something was wrong. If you write .NET tests and aren't using FluentAssertions, it is worth adding today.

Read more