Anatomy of a Good Unit Test: AAA, Naming, One Behavior and Readable Assertions
A good unit test runs one behavior through a single Act, contains no branching, and when it turns red its name and failure message explain the problem without opening the code. The limits of AAA, Method_Scenario_Expected vs plain-sentence naming, Assert.Multiple, and Shouldly, AwesomeAssertions and FluentAssertions 8's Xceed license, with real failure messages.
Version note: The code in this post was built and run with .NET 10, xUnit.net v3 4.0.2, Shouldly 4.3.0, AwesomeAssertions 9.6.0 and FluentAssertions 8.11.0.
Predict First
Bookstore's pricing has two rules: customers with 5 or more completed orders get a 5% loyalty discount, and orders of 50 EUR and above ship for free; below that, shipping costs 4.99 EUR. The code:
namespace Bookstore.Pricing;
public sealed record Customer(string Email, int CompletedOrders);
public sealed record PriceQuote(decimal Subtotal, decimal Discount, decimal Shipping)
{
public decimal Total => Subtotal - Discount + Shipping;
}
public sealed class PriceCalculator
{
public const decimal FreeShippingThreshold = 50m;
public const decimal StandardShipping = 4.99m;
public const int LoyalCustomerOrderCount = 5;
public const decimal LoyaltyRate = 0.05m;
public PriceQuote Quote(decimal subtotal, Customer customer)
{
decimal discount = customer.CompletedOrders >= LoyalCustomerOrderCount
? Math.Round(subtotal * LoyaltyRate, 2)
: 0m;
// Rule: free shipping for orders of 50 EUR and above
decimal shipping = subtotal > FreeShippingThreshold ? 0m : StandardShipping;
return new PriceQuote(subtotal, discount, shipping);
}
}
The test written for the shipping rule tries three amounts in a loop:
public sealed class PriceCalculatorLoopTests
{
[Fact]
public void Test1()
{
PriceCalculator calc = new();
decimal[] subtotals = [20m, 50m, 80m];
decimal[] expected = [4.99m, 0m, 0m];
for (int i = 0; i < subtotals.Length; i++)
{
PriceQuote q = calc.Quote(subtotals[i], new Customer("ada@example.com", 0));
Assert.Equal(expected[i], q.Shipping);
}
}
}
The test fails. Just by looking at the test output, can you tell which amount was calculated wrong? Without reading the code, only from the message on the CI (Continuous Integration) screen.
The Answer: The Message Does Not Say Which Row
failed Bookstore.Pricing.Tests.PriceCalculatorLoopTests.Test1 (8ms)
from …/p03/Bookstore.Pricing.Tests/bin/Debug/net10.0/Bookstore.Pricing.Tests.dll (net10.0|arm64)
Assert.Equal() Failure: Values differ
Expected: 0
Actual: 4.99
at Bookstore.Pricing.Tests.PriceCalculatorLoopTests.Test1() in …/p03/Bookstore.Pricing.Tests/PriceCalculatorLoopTests.cs:17
"Expected 0, got 4.99". Is that for 50 or for 80? The message does not say. The test name (Test1) says nothing. Because the loop stops at the first failure, we do not even know whether 80 works. To find the bug you have to open the code and step into the loop with a debugger. The real cause is where the comment and the code disagree: the rule says "50 and above", the code uses >. In this post we take apart the anatomy of tests in which such a bug can be read at first glance.
Arrange, Act, Assert and Their Limits
AAA (Arrange, Act, Assert) splits a test into three sections: prepare the environment, run the behavior under test once, check the result. Post 29 of the ASP.NET Core Web API (Application Programming Interface) series introduces this structure; here we look at the limits of the rule. The same rule, with tests that have no loop and have names:
public sealed class FreeShippingTests
{
private const decimal StandardShipping = 4.99m;
private static readonly Customer NewCustomer = new("ada@example.com", CompletedOrders: 0);
private readonly PriceCalculator calculator = new();
[Fact]
public void Order_below_50_pays_standard_shipping()
{
PriceQuote quote = calculator.Quote(49.99m, NewCustomer);
Assert.Equal(StandardShipping, quote.Shipping);
}
[Fact]
public void Order_of_exactly_50_ships_free()
{
PriceQuote quote = calculator.Quote(50m, NewCustomer);
Assert.Equal(0m, quote.Shipping);
}
[Fact]
public void Order_above_50_ships_free()
{
PriceQuote quote = calculator.Quote(80m, NewCustomer);
Assert.Equal(0m, quote.Shipping);
}
}
The output now speaks for itself: only Order_of_exactly_50_ships_free fails, the other two pass. The bug is at the 50 boundary. Four rules were at work in this transformation.
One Act. In each test the code under test is called once. If you see two calls in the Act section (first AddLine, then Quote, then AddLine again), either the test checks more than one behavior or the API itself hides something. Khorikov reads the second case as a design signal: if one operation needs two calls, a method combining those two calls may be missing.
No if, for or switch in a test. Branching in test code means the test itself needs testing. A loop wipes the context from the failure message and stops at the first failure. If you want to try the same rule with different inputs, the right tool is [Theory], the topic of the next post.
A meaningful constant instead of a magic value. Had 4.99m appeared three times in the test, a reader would have to guess whether it was the shipping fee or something else. The StandardShipping constant states the intent. Note that the constant is defined in the test class; PriceCalculator.StandardShipping from production is not used. Taking the expected value from production code means the test accepts a wrong value in production too.
Minimal input. NewCustomer's email address does not affect the shipping rule, but the constructor requires it, so it lives in one place, in a constant whose name states its intent. Every value visible in a test tells the reader "this matters". Hiding values that do not affect the behavior is the starting point of the test data builders in post 10.
Naming: Method_Scenario_Expected or a Sentence?
Two naming styles are common in the .NET world. The Method_Scenario_Expected style, popularized by Roy Osherove's The Art of Unit Testing: Quote_SubtotalEqualsThreshold_ReturnsZeroShipping. Microsoft's unit testing best practices document also recommends this three-part structure. Khorikov, on the other hand, says this style ties the method name to the test, so test names go stale when the method is renamed, and recommends plain sentences that describe the behavior in the language of the domain: Order_of_exactly_50_ships_free.
I prefer the second, because a test name is the title of a bug report, and the person reading that report often does not know the method names. Whichever you pick, there is one rule: the name must say what the test guarantees. Test1, QuoteTest and ShouldWork say nothing.
xUnit offers a setting to display these sentences more readably. xunit.runner.json:
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"methodDisplay": "method",
"methodDisplayOptions": "replaceUnderscoreWithSpace"
}
The same failing test now looks like this:
failed Order of exactly 50 ships free (0ms)
Expected: 0
Actual: 4.99
methodDisplay: method drops the class name from the display; if the class name carries information too (FreeShippingTests), staying with the default classAndMethod may be better. 4.0 also added removeAsyncSuffix to this list.
Organizing Test Classes by Behavior
In the first post we said the "one test class per class" reflex ties tests to the class structure. The alternative is to name test classes after behaviors: FreeShippingTests, LoyaltyDiscountTests, OrderCancellationTests. If PriceCalculator is split tomorrow into ShippingPolicy and DiscountPolicy, the names of these test classes stay correct; the tests inside only change which class they call.
A test suite organized this way reads like documentation. The three rows under FreeShippingTests in Test Explorer are the specification of the shipping rule: paid below 50, free at exactly 50, free above. A new developer can learn the rule by reading the test list instead of the code. Writing test names with this in mind also gives you the measure of a good test name: can someone reading the list reconstruct the rule?
Readable Assertions
A test's value shows the moment it turns red; at that moment the only thing to read is the failure message. xUnit's built-in Assert does well with records, because a record's ToString writes out every property:
[Fact]
public void Builtin_record_equality()
{
PriceQuote quote = calculator.Quote(50m, Loyal);
Assert.Equal(new PriceQuote(Subtotal: 50m, Discount: 2.50m, Shipping: 0m), quote);
}
Assert.Equal() Failure: Values differ
Expected: PriceQuote { Subtotal = 50, Discount = 2.50, Shipping = 0, Total = 47.50 }
Actual: PriceQuote { Subtotal = 50, Discount = 2.50, Shipping = 4.99, Total = 52.49 }
Checking several aspects of one result can be part of checking one behavior: for a loyal customer's 50 EUR order, discount, shipping and total mean something together. Consecutive Asserts stop at the first failure; Assert.Multiple runs all of them and reports the failures together:
[Fact]
public void Loyal_customer_quote_at_50_has_discount_free_shipping_and_total()
{
PriceQuote quote = new PriceCalculator().Quote(50m, Loyal);
Assert.Multiple(
() => Assert.Equal(2.50m, quote.Discount),
() => Assert.Equal(0m, quote.Shipping),
() => Assert.Equal(47.50m, quote.Total));
}
Assert.Multiple() Failure: Multiple failures were encountered
---- Assert.Equal() Failure: Values differ
Expected: 0
Actual: 4.99
---- Assert.Equal() Failure: Values differ
Expected: 47.50
Actual: 52.49
The line needs to be drawn carefully here: the "one behavior" rule does not mean "one Assert". If the result of one behavior shows up in three properties, checking all three is right. What is wrong is verifying two unrelated behaviors (like the discount rule and the cancellation rule) in the same test.
Shouldly, AwesomeAssertions and FluentAssertions 8's License
The built-in Assert's message does not say what was compared; it only gives the values. That gap is exactly what assertion libraries sell. The same failing check in three libraries:
// Shouldly
quote.Shipping.ShouldBe(0m);
// AwesomeAssertions (using AwesomeAssertions;)
quote.Should().BeEquivalentTo(new { Subtotal = 50m, Discount = 2.50m, Shipping = 0m, Total = 47.50m });
// FluentAssertions 8 (using FluentAssertions;)
quote.Shipping.Should().Be(0m);
Shouldly.ShouldAssertException : quote.Shipping
should be
0m
but was
4.99m
Expected property quote.Shipping to be 0M, but found 4.99M.
Expected property quote.Total to be 47.50M, but found 52.49M.
Expected quote.Shipping to be 0M, but found 4.99M (difference of 4.99).
All three bring the expression from the test's source code (quote.Shipping) into the message; Shouldly does this by reading the source line where the call was made. AwesomeAssertions' equivalency comparison matches an anonymous object against the record property by property and reports both differences at once (the output is followed by a long list of the comparison settings that were used).
Beyond technical differences, the license matters in the choice. FluentAssertions announced a partnership with Xceed in January 2025; with 8.0 the license moved from Apache 2.0 to Xceed's commercial license. Non-commercial use is free, commercial use requires a paid subscription; according to InfoQ's report, 129.95 USD per developer per year. The 7.x line stays on Apache 2.0. Tests run with 8.11.0 get Xceed's license warning in their output on every run: "You may use Fluent Assertions free of charge for non-commercial use only." AwesomeAssertions is a community-started fork created in response to this change; its README commits to keeping the Apache 2.0 license. With v9 its namespace became AwesomeAssertions, so moving from FluentAssertions is a package and using change. Shouldly 4.3.0, for its part, uses the three-clause BSD (Berkeley Software Distribution) license, BSD-3-Clause.
In this series we use the built-in Assert. The reason is simple: no extra dependency, the xUnit analyzers know it, and with records its messages are readable enough. In a project where the team already uses Shouldly or AwesomeAssertions there is no reason not to use them either; what matters is having a single assertion style per project.
In Production
Check that failure messages are readable in CI. Look at a screenshot of a red build: do the test name and the message explain the problem without opening the code? If not, the problem is in the test, not in the code. When "why did this test break?" is a frequent question in the team, naming and assertions are weak.
License checks are part of dependency updates. A tool like Dependabot or Renovate can bump FluentAssertions from 7 to 8 automatically, and in a commercial project this changes the license terms without anyone noticing. Test dependencies should go through license checks just like production dependencies; in post 07 we will come back to this with Moq's 2023 incident.
Careful with floating point. Assert.Equal(0.1 + 0.2, 0.3) fails for double; for money we use decimal anyway. If you need to compare double values, use Assert.Equal(expected, actual, precision) or the overloads that take a tolerance.
Common Pitfalls
Asserting several behaviors in one test. "The order amount is calculated correctly and stock is restored after cancellation" is two tests. Merged into one, when the first part breaks the second part never runs, and the test name cannot tell which one broke.
Assertions whose failure messages say nothing. When Assert.True(quote.Shipping == 0m) fails, the message says "Expected: True, Actual: False"; the values are lost. Pick assertions that state the intent: Assert.Equal for comparisons, Assert.Contains/Assert.Single for collections, Assert.IsType for types. When I tried it, the xUnit analyzers (2.2.0) gave no warning for Assert.True(shipping == 0m) or Assert.True(list.Count == 1); this discipline is up to you.
Asserting implementation details. Checking via reflection that a private cache dictionary inside PriceCalculator has been filled, just because it exists. Or checking the exact text of a log line. These are not behavior but the way the behavior happens to be implemented today; they produce false alarms at the first refactoring.
Computing the expected value with production code. Assert.Equal(subtotal * PriceCalculator.LoyaltyRate, quote.Discount) is a copy of the production formula: if the formula is wrong, the test makes the same mistake. Write the expected value as a hand-calculated constant: 5% of 50 EUR is 2.50 EUR.
A Small Experiment
★☆☆ Fix the bug in PriceCalculator (>= instead of >) and delete the loop test. Have someone who does not know the rule read the names of the three tests in FreeShippingTests: can they describe the rule correctly?
★★☆ Write three tests for the loyalty discount: a customer with 4 orders gets no discount, one with 5 does, and the discount is rounded to the cent (for example, 1.67 EUR on a 33.33 EUR order). Write the expected values without using the production constants.
★★★ Search a test file in your project for usages like Assert.True(x == y) or Assert.True(list.Count == 1). Replace them with assertions that state the intent, deliberately make one of them fail, and compare the failure messages before and after.
In the next post we look at the right replacement for the loop, data-driven tests: [Theory], MemberData, the type-safe TheoryData, TheoryDataRow carrying per-row metadata, and MatrixTheoryData for combinations.