AwesomeAssertions and Shouldly Guide: Should(), BeEquivalentTo Options, Collections, Exceptions, Dates and Assertion Scopes
In AwesomeAssertions, BeEquivalentTo compares types that override Equals by calling Equals instead of their members; with entities equal by Id this treats two different orders as equal, and the fix is ComparingByMembers<Entity>(). Excluding, For().Exclude, Using/WhenTypeIs, WithStrictOrdering, Throw/ThrowExactly, BeCloseTo and AssertionScope; Shouldly's source-based messages and a FluentAssertions 7 to AwesomeAssertions 9 migration with real build errors.
Version note: The code in this post was built and run with .NET 10, xUnit.net v3 4.0.3, AwesomeAssertions 9.6.0 and Shouldly 4.3.0; the migration example used FluentAssertions 7.2.0.
Predict First
Post 03 introduced the assertion libraries together with their licences, and the series stayed with the built-in Assert. When you land in a project whose team already uses AwesomeAssertions or Shouldly, what you need is the API itself; this post walks through it family by family using Bookstore, the online bookstore used as the shared example across this site's series. A question first. The entities in Bookstore's persistence layer derive from a common base class and, as in the DDD books, are equal by Id:
public abstract class Entity
{
public Guid Id { get; init; }
public override bool Equals(object? obj) => obj is Entity other && other.GetType() == GetType() && other.Id == Id;
public override int GetHashCode() => Id.GetHashCode();
}
public sealed class OrderRecord : Entity
{
public required string OrderNumber { get; init; }
public required string CustomerEmail { get; init; }
public OrderStatus Status { get; set; }
public List<OrderLineRecord> Lines { get; init; } = [];
public DateTimeOffset CreatedAtUtc { get; init; }
public string CreatedBy { get; init; } = "";
public DateTimeOffset? UpdatedAtUtc { get; set; }
}
A test that checks a saved order reads back unchanged simulates a bug in the read code:
[Fact]
public void Reloaded_order_is_equivalent_to_the_saved_one()
{
OrderRecord saved = SavedOrder();
OrderRecord reloaded = SavedOrder();
reloaded.Status = OrderStatus.Draft;
reloaded.Should().BeEquivalentTo(saved);
}
saved is Placed, reloaded is Draft. Since BeEquivalentTo compares properties one by one, the test should fail. Does it?
The Answer: A Type That Overrides Equals Is Compared by Value
The test passes. When AwesomeAssertions' equivalency engine sees that a type overrides Equals, it treats it like a value type and calls Equals without going into the members. Entity.Equals only looks at Id, so the two orders count as equal. You have to ask for a member-wise comparison explicitly:
reloaded.Should().BeEquivalentTo(saved, options => options.ComparingByMembers<OrderRecord>());
Expected property reloaded.Status to equal OrderStatus.Placed {value: 1} by value, but found OrderStatus.Draft {value: 0}.
With configuration:
- Prefer the declared type of the members
- Compare enums by value
- Compare tuples by their properties
- Compare anonymous types by their properties
- Compare records by their members
- Compare Bookstore.Ordering.OrderRecord by its members
- Include non-browsable members
- Match member by name (or throw)
- Be strict about the order of items in byte arrays
- Without automatic conversion.
That is only half a fix. OrderLineRecord is an Entity too, and ComparingByMembers<OrderRecord>() applies only to the order type: when I changed a line's quantity from 2 to 5, the test still passed. ComparingByMembers<Entity>(), on the other hand, applies to every type deriving from the base class and caught the line difference as well ("Expected property Changed().Lines[0].Quantity to be 2, but found 5."). To avoid writing this in every test, we configure it once for the whole assembly:
[assembly: AssertionEngineInitializer(typeof(Bookstore.Ordering.AwesomeAssertions.Tests.AssertionSetup), nameof(Bookstore.Ordering.AwesomeAssertions.Tests.AssertionSetup.Initialize))]
namespace Bookstore.Ordering.AwesomeAssertions.Tests;
public static class AssertionSetup
{
// Entities override Equals by Id; compare them member by member in every BeEquivalentTo
public static void Initialize() =>
AssertionConfiguration.Current.Equivalency.Modify(options => options.ComparingByMembers<Entity>());
}
AssertionEngineInitializer calls the given static method before the first assertion runs. With this setting the Predict First test fails as it should, without any change to the test. Records are the opposite case: since 6.0 they are compared by their members by default ("Compare records by their members" in the list above).
The Should() Chain: Basic Assertions
In AwesomeAssertions every assertion starts with Should() and continues with the methods of the returned type. And continues the chain on the same subject, while Which moves to the element the previous assertion found:
[Fact]
public void Dto_total_and_status()
{
OrderDto dto = OrderMapper.ToDto(saved);
dto.Total.Should().Be(43.65m);
dto.Status.Should().Be(OrderStatus.Placed);
dto.CustomerEmail.Should().EndWith("@example.com").And.NotContainAny(" ", "\t");
}
For numbers there are Be, BePositive, BeGreaterThan, BeInRange, and BeApproximately(expected, precision) for double/decimal. For strings there are StartWith, Contain, Match with wildcards and MatchRegex; for a case-insensitive comparison use BeEquivalentTo, which on a string means "same text, casing does not matter". Messages use the expression from the source code; when order.Total.Should().Be(37.40m) fails it says:
Expected order.Total to be 37.40M, but found 34.90M (difference of -2.50).
Printing the difference is small but useful: a 2.50 EUR difference immediately points at the price of a single line.
BeEquivalentTo Options
This is where the library earns its keep. Let us verify that the OrderDto built from the persistence entity carries everything in the entity except Id and the audit fields. The DTO rounds prices to two decimals and sorts lines by title; in the entity, prices come from the price list with four decimals:
[Fact]
public void Dto_carries_everything_but_ids_and_audit_fields()
{
OrderDto dto = OrderMapper.ToDto(saved);
dto.Should().BeEquivalentTo(saved, options => options
.Excluding(o => o.Id)
.Excluding(o => o.CreatedAtUtc)
.Excluding(o => o.CreatedBy)
.Excluding(o => o.UpdatedAtUtc)
.For(o => o.Lines).Exclude(l => l.Id)
.Using<decimal>(ctx => ctx.Subject.Should().BeApproximately(ctx.Expectation, 0.005m))
.WhenTypeIs<decimal>());
}
The expectation, here the entity, drives the comparison: every member of the entity is looked up on the DTO, and the DTO's extra Total is ignored. Run without any options, the message lists everything that is missing:
Expected property OrderMapper.ToDto(saved).Lines[0].UnitPrice to be 12.4950M, but found 12.50M.
Expectation has property Lines[0].Id that the other object does not have.
Expectation has property Lines[1].Id that the other object does not have.
Expected property OrderMapper.ToDto(saved).Lines[2].UnitPrice to be 8.7499M, but found 8.75M.
Expectation has property Lines[2].Id that the other object does not have.
Expectation has property CreatedAtUtc that the other object does not have.
Expectation has property CreatedBy that the other object does not have.
Expectation has property UpdatedAtUtc that the other object does not have.
Expectation has property Id that the other object does not have.
The options follow directly from this list. Excluding removes a single member, and For(o => o.Lines).Exclude(l => l.Id) removes one member from every element of a collection. For a per-type rule, Using<T>(...) supplies the comparison itself and WhenTypeIs<T>() selects the members it applies to; here every decimal is compared with a half-cent tolerance. Others: Including keeps only the members you pick, ExcludingMembersNamed("Id") removes members with that name at every level, ExcludingMissingMembers() silently skips members that exist on the expectation but not on the subject, and WithMapping maps members with different names. ExcludingMissingMembers is tempting but silences the list above; spelling out what you exclude also documents what the test verifies.
Order does not matter by default. Listing the DTO's lines by title is a requirement, so that test asks for order explicitly:
[Fact]
public void Dto_lines_are_listed_by_title()
{
OrderDto dto = OrderMapper.ToDto(saved);
dto.Lines.Should().BeEquivalentTo(
[
new { Title = "Dune", Quantity = 2, UnitPrice = 12.50m },
new { Title = "Emma", Quantity = 1, UnitPrice = 8.75m },
new { Title = "The Hobbit", Quantity = 1, UnitPrice = 9.90m },
],
options => options.WithStrictOrdering());
}
When the order is wrong, the message prints which string was expected at which index and marks the first differing character with arrows:
Expected property OrderMapper.ToDto(saved).Lines[1].Title to be the same string, but they differ at index 0:
↓ (actual)
"Emma"
"The Hobbit"
↑ (expected).
WithStrictOrderingFor(o => o.Lines) requires order for a single collection only; WithoutStrictOrdering() undoes a global setting in one test.
Collections, Exceptions and Dates
[Fact]
public void Lines_collection_assertions()
{
dto.Lines.Should().HaveCount(3)
.And.OnlyHaveUniqueItems(l => l.Isbn)
.And.BeInAscendingOrder(l => l.Title, StringComparer.Ordinal)
.And.AllSatisfy(l => l.Quantity.Should().BePositive());
dto.Lines.Should().ContainSingle(l => l.Isbn == Dune)
.Which.Quantity.Should().Be(2);
dto.Lines.Should().ContainEquivalentOf(new { Isbn = Emma, UnitPrice = 8.75m })
.And.NotContain(l => l.UnitPrice <= 0m);
}
Like xUnit's Assert.Single, ContainSingle both checks and hands you the element through Which; when it fails it says how many elements it found ("to contain a single item matching (l.Quantity == 1), but 2 such items were found."). ContainEquivalentOf searches the collection for an element that matches under the equivalency rules, and AllSatisfy applies an assertion to every element.
For exceptions you first define an Action or a Func<Task>:
[Fact]
public void Placing_twice_is_rejected()
{
Order order = NewOrder();
order.MarkPlaced();
Action act = () => order.MarkPlaced();
act.Should().ThrowExactly<InvalidOperationException>()
.WithMessage("Order BS-2026-000123 is already *");
}
[Fact]
public async Task Insufficient_stock_names_the_book()
{
OrderPlacementService service = new(new InMemoryStockLedger().Receive(Dune, 1));
Func<Task> act = () => service.PlaceAsync(NewOrder(), TestContext.Current.CancellationToken);
await act.Should().ThrowAsync<InsufficientStockException>()
.Where(e => e.Isbn == Dune && e.Requested == 2 && e.Available == 1);
}
Here there is a difference from xUnit: Throw<T> also accepts derived types, and an exact match needs ThrowExactly<T>. WithMessage uses wildcards (*, ?); checking the meaningful part of the message instead of pinning all of it keeps the test stable when the wording changes. Where puts a condition on the exception's properties, WithParameterName checks the parameter name on the ArgumentException family, and WithInnerException<T> goes into the inner exception. In async code, do not forget to await ThrowAsync; if you do, the test passes without the assertion ever completing (see the pitfalls below).
For dates, BeCloseTo compares with a tolerance. The AwesomeAssertions.Extensions namespace provides readable durations such as 2.Seconds() and 7.Days():
[Fact]
public void Campaign_ends_one_week_after_it_starts()
{
Campaign campaign = Campaign.StartNow("AUTUMN26", 0.15m, TimeSpan.FromDays(7), TimeProvider.System);
campaign.EndsAtUtc.Should().BeCloseTo(DateTimeOffset.UtcNow.AddDays(7), 2.Seconds());
campaign.EndsAtUtc.Should().BeExactly(campaign.StartsAtUtc + 7.Days());
}
With a wrong expectation that compares the campaign end with six days from now, the message prints the difference down to the millisecond: "Expected c.EndsAtUtc to be within 2s from <2026-11-20 22:22:07.219512 +0h>, but <2026-11-21 22:22:07.219511 +0h> was off by 23h, 59m, 59s, 999ms and 999.0µs." This test, tied to the real clock, works but is not ideal; pinning the TimeProvider with FakeTimeProvider and using BeExactly (post 11) removes the need for any tolerance. The second line is a small example: the relation between start and end does not depend on the clock and can be compared exactly.
AssertionScope: Collecting Every Failure in One Report
Consecutive assertions stop at the first failure. Assertions inside an AssertionScope collect their failures and report them together when the scope closes:
using (new AssertionScope())
{
order.Status.Should().Be(OrderStatus.Placed);
order.Total.Should().Be(34.90m);
order.Lines.Should().HaveCount(2);
}
With an order that was never placed and a wrong expected total, the output is:
Expected order.Status to be OrderStatus.Placed {value: 1}, but found OrderStatus.Draft {value: 0}.
Expected order.Total to be 37.40M, but found 34.90M (difference of -2.50).
Without the scope, the same test reports only the first line. This is the counterpart of xUnit's Assert.Multiple, and the rule from post 03 applies here too: use it to check several aspects of one behavior together, not to collect unrelated behaviors in one test.
Shouldly: ShouldBe and Messages Built from Source Code
Shouldly has no chain; every assertion is an extension method directly on the subject:
[Fact]
public void Order_total_and_lines()
{
Order order = NewOrder();
order.Total.ShouldBe(34.90m);
order.Lines.Count.ShouldBe(2);
order.Lines.ShouldContain(l => l.Isbn == Dune && l.Quantity == 2);
order.Lines.ShouldAllBe(l => l.UnitPrice > 0m);
order.CustomerEmail.ShouldEndWith("@example.com");
}
[Fact]
public void Order_lines_match_regardless_of_order()
{
Order order = NewOrder();
order.Lines.ShouldBe(
[new OrderLine(Hobbit, 1, 9.90m), new OrderLine(Dune, 2, 12.50m)],
ignoreOrder: true);
}
[Fact]
public void Placed_order_satisfies_all_conditions()
{
Order order = NewOrder();
order.MarkPlaced();
order.ShouldSatisfyAllConditions(
() => order.Status.ShouldBe(OrderStatus.Placed),
() => order.Total.ShouldBe(34.90m),
() => order.Lines.ShouldNotBeEmpty());
}
[Fact]
public async Task Insufficient_stock_names_the_book()
{
OrderPlacementService service = new(new InMemoryStockLedger().Receive(Dune, 1));
InsufficientStockException ex = await Should.ThrowAsync<InsufficientStockException>(
() => service.PlaceAsync(NewOrder(), TestContext.Current.CancellationToken));
ex.Available.ShouldBe(1);
}
[Fact]
public void Campaign_ends_one_week_after_it_starts()
{
Campaign campaign = Campaign.StartNow("AUTUMN26", 0.15m, TimeSpan.FromDays(7), TimeProvider.System);
campaign.EndsAtUtc.ShouldBe(DateTimeOffset.UtcNow.AddDays(7), tolerance: TimeSpan.FromSeconds(2));
}
ShouldSatisfyAllConditions is the counterpart of AssertionScope and lists the failures with numbers:
Shouldly.ShouldAssertException : order
should satisfy all the conditions specified, but does not.
The following errors were found ...
--------------- Error 1 ---------------
order.Status
should be
OrderStatus.Placed
but was
OrderStatus.Draft
--------------- Error 2 ---------------
order.Total
should be
37.40m
but was
34.90m
Where do the order.Status and order.Total expressions in the message come from? Shouldly 4.x finds the calling line through the stack trace and reads it from the source file. I ran into a side effect of this: when a one-line test method ([Fact] public void Total_one_liner() => NewOrder().Total.ShouldBe(37.40m);) failed, the message became "[Fact] public void Total_one_linerNewOrder().Total should be 37.40m but was 34.90m"; the start of the line leaked into the expression. When the source file cannot be reached, the expression disappears entirely: I built the test, moved the source file away and ran it with --no-build, and the message became "25.00m should be 37.40m but was not", with the actual value standing in for the subject. In a CI setup that does not run the tests on the machine that built them, such as running a build artifact on another agent, read the messages with that in mind. According to the release notes, the Shouldly 5.0 previews replace this mechanism with [CallerArgumentExpression], and ShouldSatisfyAllConditions gives way to ShouldSatisfy, with the old name kept as an obsolete alias.
Know two behavioral differences. Should.Throw<T> and ThrowAsync<T> accept derived types: Should.Throw<ArgumentException> passed for a call that throws ArgumentOutOfRangeException. ShouldBeEquivalentTo in 4.3.0 compares types as well; comparing a DTO with an anonymous object fails with a message saying the expected type is an anonymous type and the actual type is Bookstore.Ordering.OrderDto. For two objects of the same type, it shows the path to the difference (dto → Lines → Element [0] → UnitPrice) and the values. There are no options like AwesomeAssertions' Excluding/Using in 4.x; the 5.0 preview.2 notes say ShouldBeEquivalentTo was rewritten and EquivalencyOptions were added, but before taking a preview into a production test project, read the behavior changes: some 4.x failures now pass, and string assertions are now case-sensitive by default.
Moving Off FluentAssertions
Post 03 covered the licensing side; on the day of writing NuGet shows the same picture: the licence field of FluentAssertions 7.2.0 is Apache-2.0, while from 8.0.0 to 8.11.0 it is the Xceed licence file shipped with the package. AwesomeAssertions' release notes describe 8.0.0 as an Apache-2.0 licensed, drop-in fork of FluentAssertions; with 9.0 the namespace and assembly name became AwesomeAssertions, and according to the notes 9.0 has no functional changes. Shouldly is licensed under BSD-3-Clause.
In an old Bookstore test project written with FluentAssertions 7.2.0, I only changed the package:
<PackageReference Include="AwesomeAssertions" Version="9.6.0" />
CampaignTests.cs(1,7): error CS0246: The type or namespace name 'FluentAssertions' could not be found (are you missing a using directive or an assembly reference?)
CampaignTests.cs(12,20): error CS0246: The type or namespace name 'EquivalencyAssertionOptions<>' could not be found (are you missing a using directive or an assembly reference?)
After replacing the using FluentAssertions lines with using AwesomeAssertions, only the second kind of error remained. Those do not come from the namespace but from the renames in FluentAssertions 8.0: EquivalencyAssertionOptions<T> became EquivalencyOptions<T>, and then ExcludingNestedObjects() became WithoutRecursing(), which showed up as a CS1061 error. After three fixes the test was green. So if you are coming from 8.x, the job is mostly find-and-replace; from 7.x, the 8.0 breaking change list sits in between: AssertionChain instead of Execute.Assertion, RespectingRuntimeTypes becoming PreferringRuntimeMemberTypes, the removal of the HttpResponseMessage assertions, and so on. For a large solution the AwesomeAssertions docs suggest an intermediate step: first declare the old namespaces as global Using items in the csproj, delete the using lines from the files, then change the package and the global usings together.
Moving to Shouldly cannot be done with find-and-replace: the chain structure, the equivalency options and the exception API are different. With thousands of FluentAssertions tests, moving to AwesomeAssertions is a few hours of work and moving to Shouldly a few weeks; decide accordingly.
In Production
Keep the global equivalency configuration in one place. Settings such as ComparingByMembers<Entity>() made through AssertionEngineInitializer affect every test in the assembly; spreading them across static constructors of test classes produces results that depend on test execution order.
The "With configuration" list at the end of a failure message looks noisy, but it is the actual set of settings in use. The answer to "why does this test pass?" is often there; I did notice, however, that global settings, here ComparingByMembers<Entity>, do not show up in that list, so check the initializer before trusting the list.
Pin package versions and read the release notes. FluentAssertions' move from 7 to 8 was a licence change; Shouldly 5.0's notes change equivalency and string behavior too. With central package management (Directory.Packages.props) the versions of test packages live in a single file and an update shows up in a pull request.
Common Pitfalls
Using BeEquivalentTo for everything. If the only purpose of a test is to verify the total, dto.Total.Should().Be(43.65m) is enough; comparing the whole DTO through equivalency makes it harder to say which behavior broke when the test fails and leaves the reader unsure what the test verifies. Save equivalency for tests that say "every field must be carried over", such as mapping tests.
Default comparison behavior changing between versions. Comparing records by members became the default in 6.0; in the Shouldly 5.0 preview, ShouldBeEquivalentTo now ignores Equals overrides. A test suite that stays green after a package update does not mean the behavior stayed the same; read the "breaking" section of the release notes.
Types that override Equals. The Predict First trap is not limited to entities: in any value object whose Equals looks at only a few fields, BeEquivalentTo trusts that Equals.
Not awaiting an async exception assertion. act.Should().ThrowAsync<T>() returns a Task; if it is not awaited, the test finishes without waiting for the result. With enough stock, so that no exception was ever thrown, both tests I wrote without await passed. In an async test method the compiler raised CS4014; in a synchronous void test method there was no warning at all. TreatWarningsAsErrors catches the first; only code review catches the second.
A Small Experiment
★☆☆ Comment out the global setting in AssertionSetup and run the Predict First test. Then restore it and try the same test with ComparingByMembers<OrderRecord>(), changing a line's quantity.
★★☆ Rewrite Dto_carries_everything_but_ids_and_audit_fields with ExcludingMembersNamed instead of the Excluding chain. Which version states the test's intent more clearly?
★★★ Change the Shouldly test Dto_is_equivalent_to_the_expected_shape so that the expected side is an anonymous object, and compare 4.3.0 with the 5.0 preview in a separate project. In which version does it pass?
In the next post we move to mocking: NSubstitute's Returns, argument matchers, When..Do, Received, Raise, ForPartsOf and its analyzer, using the Payments and Notifications modules.