The xUnit v3 Assert Guide: Equal, Equivalent, Collection, Throws, Raises, Skip and Your Own Assertion Helpers
xUnit v3's built-in Assert covers most everyday needs without an extra package; the real work is picking the right method and knowing its defaults. Assert.Equivalent ignores order and extra properties by default, and in strict mode it reports a collection failure with a misleading message. Equal's tolerance and string options, Collection, Throws/ThrowsAsync, Raises, Skip and your own helper with IAssertionException, with real failure messages.
Version note: The code in this post was built and run with .NET 10, xUnit.net v3 4.0.3 and xunit.analyzers 2.3.0; the tests ran through dotnet test on Microsoft.Testing.Platform.
Predict First
This post does not teach concepts; it walks through xUnit v3's built-in Assert class family by family, with the methods you use every day. The examples come from Bookstore, the online bookstore used as the shared example across this site's series: a cart total, order lines, insufficient stock and the event raised when an order is placed. A question first. The order has two lines: first 2 copies of Dune (12.50 EUR), then 1 copy of The Hobbit (9.90 EUR). Two tests write the same expectation in a different order and without prices:
[Fact]
public void Lines_are_two_dune_and_one_hobbit()
{
Order order = DuneAndHobbit();
Assert.Equivalent(
new[]
{
new { Isbn = Hobbit, Quantity = 1 },
new { Isbn = Dune, Quantity = 2 },
},
order.Lines);
}
[Fact]
public void Lines_are_two_dune_and_one_hobbit_strict()
{
Order order = DuneAndHobbit();
Assert.Equivalent(
new[]
{
new { Isbn = Hobbit, Quantity = 1 },
new { Isbn = Dune, Quantity = 2 },
},
order.Lines,
strict: true);
}
OrderLine is a record: OrderLine(string Isbn, int Quantity, decimal UnitPrice). Which test passes? If one fails, does its message describe the problem correctly?
The Answer: Equivalent Ignores Extra Members
The first test passes, the second fails:
failed Bookstore.Ordering.Tests.OrderLinesPredictTests.Lines_are_two_dune_and_one_hobbit_strict (3ms)
Assert.Equivalent() Failure: Collection value not found
Expected: { Isbn = "9780261103344", Quantity = 1 }
In: [OrderLine { Isbn = 9780441172719, Quantity = 2, UnitPrice = 12.50 }, OrderLine { Isbn = 9780261103344, Quantity = 1, UnitPrice = 9.90 }]
Assert.Equivalent is lenient about two things by default. It ignores order in collections: for every expected element it looks for an equivalent element anywhere in the actual collection. For objects it compares only the members on the expected side; the extra UnitPrice on the actual object is never checked. That is why the first test is green and why it is dangerous: it never verifies the price, so a change that writes the wrong price into a line will not break it.
strict: true treats extra members as a failure too. But look at the message: it says "Collection value not found" and shows The Hobbit line while the actual collection holds a line with exactly the same ISBN and quantity. The cause is not the order, it is UnitPrice. With a strict comparison inside a collection, xUnit reports the element it could not match, not the member that differed. The right fix is to add UnitPrice to the expected object, which we do in the Equivalent section below.
Equality and Value Assertions
Most tests are written with this family. Post 03 covered why a readable assertion matters; here we look at when each method is the right choice:
[Fact]
public void Subtotal_is_the_sum_of_line_totals()
{
Cart cart = new Cart()
.Add(Dune, "Dune", 12.50m, quantity: 2)
.Add(Hobbit, "The Hobbit", 9.90m);
Assert.Equal(34.90m, cart.Subtotal);
}
[Fact]
public void Institutional_discount_is_rounded_to_cents()
{
// 3 x 12.45 = 37.35; with 10% off 33.615 -> 33.62
Cart cart = new Cart().Add(Dune, "Dune", 12.45m, quantity: 3);
Assert.Equal(33.62m, cart.Total(discountRate: 0.10m));
}
[Fact]
public void Discounted_total_never_exceeds_the_subtotal()
{
Cart cart = new Cart().Add(Emma, "Emma", 8.75m, quantity: 4);
Assert.InRange(cart.Total(discountRate: 0.10m), 0m, cart.Subtotal);
}
[Fact]
public void Parcel_weight_is_compared_with_a_tolerance()
{
Cart cart = new Cart().Add(Hobbit, "The Hobbit", 9.90m, quantity: 3, weightKg: 0.1);
Assert.Equal(0.3, cart.WeightKg, tolerance: 0.000001);
}
decimal is the right type for money and Assert.Equal compares it exactly. double works with binary fractions, so three times 0.1 does not add up to 0.3. The same test written without a tolerance fails like this:
Assert.Equal() Failure: Values differ
Expected: 0.29999999999999999
Actual: 0.30000000000000004
The message prints the full binary representation of both values; when you see output like this, the problem is the comparison, not the calculation. For double and float you have two options: tolerance, the maximum allowed difference, or precision, where both values are rounded to that many decimal places before the comparison; there is also an overload that takes a MidpointRounding. decimal has a precision overload as well, but if rounding is part of a money rule, do it in production code and expect the exact value in the test, as the discount test above does.
For dates, the Assert.Equal(DateTimeOffset, DateTimeOffset, TimeSpan) overload limits the difference to a duration. Pinning the clock is the better fix (FakeTimeProvider, post 11); use the tolerance only when the real clock cannot be avoided.
The rest of the equality family is short. Assert.Same(cart, cart.Add(...)) checks that two references are the same instance, here that the fluent method returns the same cart rather than a new one. Assert.Null/NotNull are for lookups, and Assert.InRange is for results that have bounds but no single correct value: the discounted total sits between zero and the subtotal. Assert.Equal also accepts an IEqualityComparer<T> or a Func<T, T, bool>; if you already have a domain-specific equality rule, pass it as a comparer instead of rewriting it in the test. Assert.StrictEqual uses only the type's default comparer, EqualityComparer<T>.Default, and skips the element-by-element comparison Equal does for collections.
String and Span Assertions
[Fact]
public void Order_number_has_year_and_six_digit_sequence()
{
string number = OrderNumber.Format(2026, 123);
Assert.Matches(@"^BS-\d{4}-\d{6}$", number);
Assert.StartsWith("BS-2026-", number);
}
[Fact]
public void Receipt_lists_lines_and_total()
{
string expected = """
Order BS-2026-000123
9780441172719 x2 25.00
9780261103344 x1 9.90
Total 34.90
""";
string receipt = Receipt.Render(DuneAndHobbit());
Assert.Equal(expected, receipt, ignoreLineEndingDifferences: true, ignoreWhiteSpaceDifferences: true);
}
[Fact]
public void Isbn_prefix_is_checked_without_allocating()
{
ReadOnlySpan<char> isbn = Dune.AsSpan();
Assert.StartsWith("978", isbn);
Assert.Equal("0441172719", isbn[3..]);
}
The receipt is built with Environment.NewLine and aligns its columns with two spaces. The expected text is a raw string literal whose line endings depend on how git checked out the file. ignoreLineEndingDifferences treats \r\n, \r and \n as equal, and ignoreWhiteSpaceDifferences compares runs of whitespace as a single space; ignoreAllWhiteSpace and ignoreCase live on the same overload. Without these two settings the same test fails with:
Assert.Equal() Failure: Strings differ
↓ (pos 35)
Expected: ···"026-000123\n9780441172719 x2 25.00\n9780261103344 x1"···
Actual: ···"026-000123\n9780441172719 x2 25.00\n9780261103344 "···
↑ (pos 35)
For strings, xUnit marks the position of the first differing character with arrows and trims long text around that point. The message also tells you that if you really want to test the receipt's alignment, you have to drop the settings: with ignoreWhiteSpaceDifferences on, a bug that emits one space instead of two goes unnoticed.
Assert.Matches works with a regex, StartsWith/EndsWith/Contains with substrings, and all of them have an overload that takes a StringComparison. In v3 the same methods treat Span<char>, ReadOnlySpan<char> and Memory<char> like strings, and general Span<T> and Memory<T> get Equal, Contains and DoesNotContain. The last test checks the ISBN without copying it into a substring by using these overloads.
Collection Assertions
[Fact]
public void Lines_keep_the_cart_order()
{
Assert.Collection(order.Lines,
first => Assert.Equal(new OrderLine(Dune, 2, 12.50m), first),
second => Assert.Equal(Hobbit, second.Isbn));
}
[Fact]
public void Every_line_has_a_positive_quantity_and_price()
{
Assert.All(order.Lines, line =>
{
Assert.True(line.Quantity > 0);
Assert.True(line.UnitPrice > 0m);
});
}
[Fact]
public void Lines_are_searched_by_condition()
{
Assert.Contains(order.Lines, l => l.Isbn == Dune && l.Quantity == 2);
Assert.DoesNotContain(order.Lines, l => l.Isbn == Emma);
Assert.Distinct(order.Lines.Select(l => l.Isbn));
}
[Fact]
public void Ordered_isbns_come_from_the_catalog()
{
HashSet<string> catalog = [Dune, Hobbit, Emma];
Assert.Subset(catalog, order.Lines.Select(l => l.Isbn).ToHashSet());
}
[Fact]
public void Adding_the_same_isbn_twice_merges_the_line()
{
Cart cart = new Cart()
.Add(Dune, "Dune", 12.50m)
.Add(Dune, "Dune", 12.50m);
CartLine line = Assert.Single(cart.Lines);
Assert.Equal(2, line.Quantity);
}
Assert.Collection checks order and count together: you pass one inspector per element. If the count is off it reports "Mismatched item count" with the expected and actual counts; if an inspector fails it shows the position:
Assert.Collection() Failure: Item comparison failure
↓ (pos 1)
Collection: [OrderLine { Isbn = 9780441172719, Quantity = 2, UnitPrice = 12.50 }, OrderLine { Isbn = 9780261103344, Quantity = 1, UnitPrice = 9.90 }]
Error: Assert.Equal() Failure: Values differ
Expected: 2
Actual: 1
Assert.All checks every element against the same rule and lists the indexes of the elements that failed. Assert.Single both checks for "exactly one element" and returns that element; if you write Assert.Equal(1, list.Count), the xUnit analyzer sends you there with warning xUnit2013. Likewise Assert.True(lines.Any(...)) raises xUnit2012 and suggests Assert.Contains, because a failing Contains prints the whole collection. Assert.Subset and Superset work with sets; above we checked that the ISBNs in the order exist in the catalog. Assert.Distinct finds duplicates, and Assert.Empty and NotEmpty check for emptiness.
One distinction matters: Assert.Equal compares two collections in order and points at the first differing position, while Assert.Equivalent ignores order. If line order means something in the order, such as the order printed on the receipt, use Equal or Collection; otherwise use Equivalent. Which one you pick is part of what the test says.
Comparing Object Graphs with Equivalent
[Fact]
public void Lines_match_regardless_of_order()
{
Assert.Equivalent(
new[]
{
new { Isbn = Hobbit, Quantity = 1, UnitPrice = 9.90m },
new { Isbn = Dune, Quantity = 2, UnitPrice = 12.50m },
},
order.Lines,
strict: true);
}
[Fact]
public void Order_from_the_same_cart_differs_only_by_id()
{
Cart cart = new Cart()
.Add(Dune, "Dune", 12.50m, quantity: 2)
.Add(Hobbit, "The Hobbit", 9.90m);
Order created = Order.FromCart(Guid.NewGuid(), "BS-2026-000123", "ada@example.com", cart);
Assert.EquivalentWithExclusions(order, created, o => o.Id);
}
Assert.Equivalent compares public properties and fields recursively without looking at types, so you can put an anonymous object next to a record, or a DTO next to an entity. The first test is the fixed version of the Predict First test: the expected side now has all three members, and with strict: true a new property added to OrderLine tomorrow will not slip past the test either.
The opposite direction is a failure too: if the expected side has a member the actual object does not, Equivalent fails even without strict:
Assert.Equivalent() Failure: Mismatched member list
Expected: ["Isbn", "Quantity", "Discount"]
Actual: ["Isbn", "Quantity", "UnitPrice"]
EquivalentWithExclusions leaves specific members out of the comparison. The second test states that two orders created from the same cart are equivalent apart from Id. Next to the expression-based overload there is one that takes string paths in "Member.SubMember" form, but that path does not reach into collection elements: when I tried EquivalentWithExclusions(a, b, "Lines.UnitPrice") the prices were still compared and the test failed with "Collection value not found in member 'Lines[]'". It has none of the rich options of AwesomeAssertions' BeEquivalentTo; if you need more than this, see the next post.
Exception and Event Assertions
[Fact]
public async Task Placing_more_than_available_throws_insufficient_stock()
{
OrderPlacementService service = new(stock);
InsufficientStockException ex = await Assert.ThrowsAsync<InsufficientStockException>(
() => service.PlaceAsync(DuneAndHobbit(), TestContext.Current.CancellationToken));
Assert.Equal(Dune, ex.Isbn);
Assert.Equal(2, ex.Requested);
Assert.Equal(1, ex.Available);
}
[Fact]
public void Zero_quantity_is_rejected_with_the_parameter_name()
{
Cart cart = new();
Assert.Throws<ArgumentOutOfRangeException>("quantity", () => cart.Add(Dune, "Dune", 12.50m, quantity: 0));
}
[Fact]
public void Placing_twice_names_the_current_status()
{
Order order = DuneAndHobbit();
order.MarkPlaced();
Assert.Throws<InvalidOperationException>(
() => order.MarkPlaced(),
ex => ex.Message.Contains("already Placed") ? null : $"Message does not name the status: {ex.Message}");
}
Throws and ThrowsAsync require an exact type match: even though InsufficientStockException derives from InvalidOperationException, ThrowsAsync<InvalidOperationException> fails and the message prints both types ("Exception type was not an exact match"). To accept derived types use ThrowsAny/ThrowsAnyAsync. The overload that takes a parameter name also checks ParamName on the ArgumentException family. The third test uses v3's inspector overload: if the function returns null the assertion passes, and if it returns a string that string becomes the failure message:
Assert.Throws() Failure: Message does not name the status: Order BS-2026-000123 is already Placed.
The real trap is async code. If you write Assert.Throws<T>(() => service.PlaceAsync(...)), the lambda binds as a Func<Task> and the build stops with two errors: the overload's obsolete message (CS0619, "You must call Assert.ThrowsAsync<T> (and await the result) when testing async code.") and the analyzer's xUnit2014 error. I got the same CS0619 for a lambda of the form () => throw new X(); since the compiler can fit a throw expression to any return type, the Func<Task> overload was chosen. For synchronous code, write the lambda as a method call.
For events there are Raises, RaisesAny and their async counterparts:
[Fact]
public void Marking_placed_raises_order_placed_with_the_total()
{
Order order = DuneAndHobbit();
Assert.RaisedEvent<OrderPlacedEventArgs> raised = Assert.Raises<OrderPlacedEventArgs>(
h => order.Placed += h,
h => order.Placed -= h,
() => order.MarkPlaced());
Assert.Same(order, raised.Sender);
Assert.Equal(new OrderPlacedEventArgs(order.Id, "BS-2026-000123", 34.90m), raised.Arguments);
}
[Fact]
public void Marking_placed_notifies_status_change()
{
Order order = DuneAndHobbit();
Assert.PropertyChanged(order, nameof(Order.Status), order.MarkPlaced);
}
[Fact]
public async Task Failed_reservation_raises_no_order_placed()
{
Order order = DuneAndHobbit();
OrderPlacementService service = new(new InMemoryStockLedger());
await Assert.NotRaisedAnyAsync<OrderPlacedEventArgs>(
h => order.Placed += h,
h => order.Placed -= h,
() => Assert.ThrowsAsync<InsufficientStockException>(
() => service.PlaceAsync(order, TestContext.Current.CancellationToken)));
Assert.Equal(OrderStatus.Draft, order.Status);
}
Raises attaches the handler, runs the code, captures the event and returns it with Sender and Arguments; if the event never fires it says "No event was raised". Raises<T> requires an exact match on the argument type, while RaisesAny<T> accepts derived types. PropertyChanged checks that an object implementing INotifyPropertyChanged raised a notification for that property. The last test nests two assertions: in one flow it verifies that PlaceAsync throws when stock is short and that the Placed event is never raised.
Dynamic Skips and Your Own Assertion Helpers
v3 lets a test skip itself while it runs: Assert.Skip(reason), SkipUnless(condition, reason) and SkipWhen(condition, reason). The test that formats the price in Turkish lira makes no sense in an environment without culture data, such as small container images running in globalization-invariant mode:
[Fact]
public void Total_is_formatted_in_turkish_lira()
{
Assert.SkipUnless(CultureDataAvailable("tr-TR"), "tr-TR culture data is not available (globalization-invariant mode).");
string total = Receipt.FormatTotal(DuneAndHobbit(), "tr-TR");
Assert.Equal("₺34,90", total);
}
My first attempt wrote the condition with AppContext.TryGetSwitch("System.Globalization.Invariant", ...). When I ran the tests with DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1, the test was not skipped; it failed with CultureNotFoundException, because the mode turned on by the environment variable was not reflected in that switch. The reliable condition is to actually ask for the culture and catch CultureNotFoundException. With the same command the output is now:
skipped Bookstore.Ordering.Tests.ReceiptTests.Total_is_formatted_in_turkish_lira
tr-TR culture data is not available (globalization-invariant mode).
Use skips for "meaningless in this environment" cases, not to silence a broken test. A skipped test shows up in the CI report but counts as green.
When the same check repeats across many tests, write your own assertion helper:
internal static class OrderAssert
{
public static OrderLine HasLine(Order order, string isbn, int quantity)
{
OrderLine? line = order.Lines.FirstOrDefault(l => l.Isbn == isbn);
if (line is null || line.Quantity != quantity)
{
throw new OrderAssertException(
$"OrderAssert.HasLine() Failure: {order.OrderNumber} has no line {isbn} x{quantity}" + Environment.NewLine +
$"Lines: {string.Join(", ", order.Lines.Select(l => $"{l.Isbn} x{l.Quantity}"))}");
}
return line;
}
}
public sealed class OrderAssertException(string message) : XunitException(message);
The helper does two things the way xUnit does: it returns the line it found, so the test can carry on like after Assert.Single, and its message says what it looked for and what it found. An exception deriving from XunitException implements the IAssertionException marker interface. v3 adds a failure cause (assertion, exception or timeout) to each test result and counts exceptions implementing this interface as assertion failures. The difference shows in the output:
failed Bookstore.Ordering.Tests.FailDemo.Helper_fails (0ms)
Bookstore.Ordering.Tests.OrderAssertException : OrderAssert.HasLine() Failure: BS-2026-000123 has no line 9780441172719 x3
Lines: 9780441172719 x2, 9780261103344 x1
failed Bookstore.Ordering.Tests.FailDemo.Plain_exception_fails (3ms)
Xunit.MicrosoftTestingPlatform.XunitException: System.InvalidOperationException : Order BS-2026-000123 has no line 9780441172719 x3
A helper that throws a plain InvalidOperationException cannot be told apart from an unexpected error in the test. If you are writing a helper library that should not depend on xUnit, the interface name is enough: declaring an empty interface named IAssertionException in any namespace and implementing it gives the same result.
In Production
Treat analyzer warnings as errors. The xunit.analyzers package comes with xunit.v3; in this post's examples I saw xUnit2000 (the constant belongs on the expected side), xUnit2012 (Contains instead of Assert.True) and xUnit2013 (Single instead of Equal(1, Count)). Each leads to a more readable failure message. The analyzer does not catch everything: Assert.True(order.Lines.Count == 2) and Assert.Equal(1, lines.Count(predicate)) raised no warning on 2.3.0.
The CI environment is not your machine. Culture data, line endings, time zone and path separator change with the test environment. Take these differences out of the test with ignoreLineEndingDifferences for line endings, by choosing the culture explicitly inside the test (CultureInfo.GetCultureInfo("tr-TR")), and by guarding tests that need culture data with SkipUnless.
Read the failure message and lengthen it when needed. xUnit trims long collections and strings; methods such as Assert.OverrideMaxEnumerableLength and OverrideMaxStringLength change those limits for the current test. If you really need to see a 500-element collection in the message, the assertion is usually written at the wrong level: verify a single element or the count first.
Common Pitfalls
Writing Assert.Equal(expected, actual) the wrong way round. Assert.Equal(order.Total, 35.90m) compiles and raises xUnit2000. If you ignore the warning, the message misleads you:
Assert.Equal() Failure: Values differ
Expected: 34.90
Actual: 35.90
Here 34.90 is actually what the system produced and 35.90 is what the test expected. Debugging with a reversed message can end with "fixing" code that was correct.
Equal on double without a tolerance. For weights, ratios or averages computed as double, pass tolerance or precision. Use decimal for money and calculate the expected value by hand.
Equivalent ignoring extra properties. The Predict First test stayed green but never checked UnitPrice. If you write a partial expected object, do it on purpose; if you are verifying a whole DTO, use strict: true.
IsType requiring an exact match. Assert.IsType<InvalidOperationException>(ex) fails with "Value is not the exact type" for the derived InsufficientStockException. To accept the hierarchy, use IsAssignableFrom<T> or v3's IsType<T>(obj, exactMatch: false) overload.
A Small Experiment
★☆☆ Deliberately set the price of the Dune line in Books.DuneAndHobbit() to 0m and run Lines_are_two_dune_and_one_hobbit. Does the test fail? Then add strict: true and read the message.
★★☆ Add a price check to OrderAssert.HasLine and write a test that calls the helper twice inside Assert.Multiple. If both lines are wrong, do both show up in the message?
★★★ Write a test for Receipt.Render without ignoreWhiteSpaceDifferences and run the tests with a file checked out with \n and with \r\n line endings, by changing git's core.autocrlf. Which setting hides which bug?
In the next post we write the same checks with AwesomeAssertions and Shouldly: the Should() chain, BeEquivalentTo options (Excluding, WithStrictOrdering, Using), BeCloseTo for dates, AssertionScope to gather several failures into one report, and moving off FluentAssertions.