.NET Testing — Part 2: Writing Readable Tests with Fluent Assertions
Series Overview
This is a 3-part series on testing .NET applications:
- Getting Started with xUnit.net — Project setup, writing tests, data-driven tests, fixtures, parallelism, and advanced features
- Writing Readable Tests with Fluent Assertions (this article) — Natural-language assertions, clearer failure messages, and custom assertions
- Integration Testing with Testcontainers — Testing against real databases using Docker containers and CI setup
Why Fluent Assertions?
It’s not enough to know how to write tests — you need to write readable tests. When a test fails six months from now, the assertion should immediately tell you what went wrong and what was expected.
Fluent Assertions is a library that gives you three things:
- Readable assertions that chain naturally and read like English
- Clearer failure messages that include variable names and context
- A rich API with more assertion methods than xUnit’s built-in
Assert
Installation
1
dotnet add package FluentAssertions
The Readability Difference
Consider this standard xUnit assertion block:
1
2
3
4
Assert.Equal("9780321146533", book.ISBN);
Assert.Equal('3', book.ISBNCheckDigit);
Assert.Equal("Kent Beck", book.Author);
Assert.Equal("Test Driven Development: By Example", book.Name);
Now with Fluent Assertions:
1
2
3
4
book.ISBN.Should().Be("9780321146533");
book.ISBNCheckDigit.Should().Be('3');
book.Author.Should().Be("Kent Beck");
book.Name.Should().Be("Test Driven Development: By Example");
These read almost like English sentences: “The book’s ISBN should be 9780321146533.”
Better Failure Messages
This is where Fluent Assertions really shines. A failing xUnit assertion:
1
2
bool saveOperationResult = false;
Assert.True(saveOperationResult);
Produces:
1
2
3
Assert.True() Failure
Expected: True
Actual: False
The same test with Fluent Assertions:
1
2
bool saveOperationResult = false;
saveOperationResult.Should().BeTrue();
Produces:
1
Expected saveOperationResult to be true, but found False.
The variable name is included automatically. You can add even more context with the because parameter:
1
2
int itemCount = 5;
itemCount.Should().Be(10, "because the cart should contain both user's and gift items");
1
Expected itemCount to be 10 because the cart should contain both user's and gift items, but found 5.
Assertion Reference
Basic — All Types
1
2
3
4
sut.Should().BeNull();
sut.Should().NotBeNull();
sut.Should().BeOfType<Customer>();
sut.Should().Be(otherCustomer);
Strings
Null / empty checks:
1
2
3
4
5
6
7
theString.Should().BeNull();
theString.Should().NotBeNull();
theString.Should().BeEmpty();
theString.Should().NotBeEmpty("because the string is not empty");
theString.Should().HaveLength(5);
theString.Should().BeNullOrWhiteSpace();
theString.Should().NotBeNullOrWhiteSpace();
Casing:
1
2
theString.Should().BeUpperCased();
theString.Should().BeLowerCased();
Content:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
theString.Should().Be("exact match");
theString.Should().BeEquivalentTo("CASE INSENSITIVE MATCH");
theString.Should().BeOneOf("option1", "option2");
theString.Should().Contain("substring");
theString.Should().Contain("x", Exactly.Once());
theString.Should().Contain("x", AtLeast.Twice());
theString.Should().ContainAll("must", "have", "all");
theString.Should().ContainAny("any", "of", "these");
theString.Should().NotContain("nope");
theString.Should().StartWith("prefix");
theString.Should().EndWith("suffix");
theString.Should().StartWithEquivalentOf("PREFIX"); // case-insensitive
Pattern matching:
1
2
emailAddress.Should().Match("*@*.com"); // wildcard
someString.Should().MatchRegex("h.*\\sworld.$"); // regex
Booleans
1
2
3
theBoolean.Should().BeTrue();
theBoolean.Should().BeFalse("it's set to false");
theBoolean.Should().Be(otherBoolean);
Numeric Types
1
2
3
4
5
6
number.Should().Be(42);
number.Should().BePositive();
number.Should().BeNegative();
number.Should().BeGreaterThan(10);
number.Should().BeLessThanOrEqualTo(100);
number.Should().BeInRange(1, 100);
Dates and Times
Fluent Assertions includes expressive date builders:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var theDatetime = 1.March(2010).At(22, 15).AsLocal();
theDatetime.Should().Be(1.March(2010).At(22, 15));
theDatetime.Should().BeAfter(1.February(2010));
theDatetime.Should().BeBefore(2.March(2010));
theDatetime.Should().BeSameDateAs(1.March(2010).At(22, 16));
// Relative comparisons
theDatetime.Should().BeLessThan(10.Minutes()).Before(otherDatetime);
theDatetime.Should().BeWithin(2.Hours()).After(otherDatetime);
theDatetime.Should().BeExactly(24.Hours()).Before(appointment);
// Parts
theDatetime.Should().HaveYear(2010);
theDatetime.Should().HaveMonth(3);
theDatetime.Should().HaveDay(1);
theDatetime.Should().HaveHour(22);
Object Equivalence
BeEquivalentTo compares property values — the objects don’t even need to be the same type. This is perfect for comparing domain models to DTOs:
1
customer.Should().BeEquivalentTo(customerDto);
Key distinction:
Be()usesObject.Equals()— reference equality by defaultBeEquivalentTo()compares property values — structural equality
Collections
1
2
3
4
5
6
7
8
9
10
11
12
13
14
collection.Should().NotBeEmpty();
collection.Should().HaveCount(3);
collection.Should().OnlyHaveUniqueItems();
collection.Should().Equal("first", "second", "third"); // order matters
collection.Should().BeEquivalentTo("third", "first", "second"); // order doesn't matter
collection.Should().Contain("first").And.HaveElementAt(2, "third");
collection.Should().ContainInOrder("first", "second", "third");
collection.Should().StartWith("first");
collection.Should().EndWith("third");
collection.Should().BeInAscendingOrder();
collection.Should().BeInDescendingOrder();
Exceptions
1
2
3
4
5
6
7
8
9
// Synchronous
Action act = () => sut.BadMethod();
act.Should().Throw<ArgumentException>();
act.Should().NotThrow<NullReferenceException>();
// Asynchronous
Func<Task> act = () => sut.BadMethodAsync();
await act.Should().ThrowAsync<ArgumentNullException>();
await act.Should().NotThrowAsync();
Writing Custom Assertions
You can extend Fluent Assertions with domain-specific assertions. For example, validating Australian mobile numbers:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static class CustomAssertions
{
public static void BeAValidMobileNumber(
this StringAssertions assertions,
string because = "the string should be a valid mobile number",
params object[] becauseArgs)
{
var regex = new Regex(
@"^(\+?\(61\)|\(\+?61\)|\+?61|\(0[1-9]\)|0[1-9])?( ?-?[0-9]){7,9}$");
Execute.Assertion
.BecauseOf(because, becauseArgs)
.ForCondition(regex.IsMatch(assertions.Subject))
.FailWith(
"Expected {context:string} to be a valid mobile number{reason}, " +
"but found {0} is not valid.",
assertions.Subject);
}
}
Use it like any other assertion:
1
2
3
4
5
6
7
[Fact]
public void ValidMobileNumber_ShouldPass()
{
string mobile = "+61412345678";
mobile.Should().BeAValidMobileNumber(
"because it is a valid Australian mobile number");
}
The pattern is:
- Create a static extension method on the appropriate
*Assertionsclass (e.g.,StringAssertions,NumericAssertions<T>) - Use
Execute.Assertionto build the failure message withBecauseOfandFailWith - Use
{context:string}to include the variable name and{reason}to include the “because” text
Practical Tips
- Always add
becausefor non-obvious assertions — your future self will thank you when a CI build fails at 2 AM - Use
BeEquivalentTofor DTOs and API responses — it’s more forgiving thanEqualand handles type differences gracefully - Chain assertions with
.Andfor cleaner tests:result.Should().NotBeNull().And.BeOfType<Order>() - Don’t over-assert — one concept per test. Fluent Assertions makes it tempting to chain everything, but focused tests are easier to debug
What’s Next?
Fluent Assertions makes your unit tests readable and your failure messages actionable. But what about integration tests that need real infrastructure? In Part 3, we’ll use Testcontainers to spin up real PostgreSQL databases in Docker for true integration testing — no mocks required.