Clean Test Code: F.I.R.S.T., Readable Asserts and Test Data Builders
A clean test is fast, independent, repeatable, self-validating and timely (F.I.R.S.T.); the legacy OrderService tests break on Saturdays because of DateTime.Now and depending on run order because of the static Db. With TimeProvider and FakeTimeProvider, a test data builder, test names that read like sentences and asserts that state the reason when they fail, we turn the same rules into clean tests.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First: A Test Written on a Thursday
Someone on the team wrote two tests for the legacy OrderService. They wrote them on a Thursday, both were green, they committed and left:
public class OrderServiceTests
{
[Fact]
public void Test1()
{
var s = new OrderService();
var d = new Dictionary<string, int> { ["978-0441013593"] = 1, ["978-0553380163"] = 1 };
int id = s.ProcessData(1, d, null, false, false, 1);
Assert.True(id > 0);
var o = s.GetOrder(id);
Assert.Equal(705.38m, o.Total);
Assert.Equal(9, Db.Books[0].Stock);
}
[Fact]
public void Test2()
{
var s = new OrderService();
var d = new Dictionary<string, int> { ["978-0441013593"] = 2 };
int id = s.ProcessData(2, d, null, false, false, 1);
Assert.Equal(8, Db.Books[0].Stock);
}
}
The day I wrote this post was a Saturday. When the tests run in CI (Continuous Integration) today, which one passes and which one breaks? Does the failure message tell you why?
Test Code Is Production Code Too
Failed Bookstore.Tests.Legacy.OrderServiceTests.Test1 [1 ms]
Error Message:
Assert.Equal() Failure: Values differ
Expected: 705.38
Actual: 690.52
On Thursday, Ada's order came to 705.38 TRY (Turkish lira): a 675 TRY subtotal, a 33.75 VIP (Very Important Person) discount, 10% tax. On Saturday the DateTime.Now inside ProcessData switched on the weekend discount as well, and the total became 690.52. Nothing changed in the code; the calendar did. The test message gives only two numbers; to find the reason for the difference you have to read roughly 135 lines of ProcessData.
The rest of the story is more instructive. On Monday someone "fixes" the test and changes the expected value to 690.52. This time the suite says:
Failed Bookstore.Tests.Legacy.OrderServiceTests.Test1 [1 ms]
Error Message:
Assert.Equal() Failure: Values differ
Expected: 9
Actual: 7
But run the test on its own and it passes:
Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1, Duration: 7 ms - t10.dll (net10.0)
The cause is the static Db. Test2 ran first and brought Dune's stock down from 10 to 8; Test1 saw the same list and the stock dropped to 7. The two tests share each other's state, and the result depends on the order.
In the unit tests chapter of Clean Code (2008; second edition 2025), Robert C. Martin (Uncle Bob) explains that dirty test code can be worse than no tests at all: tests have to change as the code changes, tests that can't be read and that break at random make maintenance harder, and in the end the team deletes them or Skips them. The two tests above are at the start of exactly that road. Their names say nothing, their setup depends on real global state, and their asserts report only the outcome, not the reason. Test code deserves the same care as production code; only the priorities differ.
F.I.R.S.T.: Five Properties of a Clean Test
The unit tests chapter of Clean Code sums up clean tests with the acronym F.I.R.S.T. (Fast, Independent, Repeatable, Self-Validating, Timely). The legacy tests violate three of the five:
- Fast: a test should take milliseconds, not seconds, so it gets run on every change. The legacy tests are fast because
Dbis in memory; if they went to a real database this would break too. - Independent: tests shouldn't leave state for each other and should give the same result in any order. Because of the static
Db, the result ofTest1depends on whetherTest2ran first. - Repeatable: the same result in every environment, every day. Because of
DateTime.Now, the test passes on weekdays and breaks on weekends. - Self-Validating: the result should be just pass or fail; you shouldn't have to read a log to decide. The legacy tests do all right here.
- Timely: tests should be written just before the production code or together with it; a test written afterward runs into a design that's hard to test. That's the real reason
OrderServicecan't be tested.
Everything that follows is about achieving these five properties for the OrderPricing class we pulled out of OrderService in the ninth post.
One Concept per Test, and Naming
Test1 verifies the order id, the total and the stock decrement all at once. When the first assert breaks, the other two never run, and the test name doesn't say which concept broke. Martin's advice is a single concept per test; there can be more than one assert, as long as they describe the same behavior.
For naming, there are two common styles in the .NET world. The first is the MethodName_StateUnderTest_ExpectedBehavior pattern that Roy Osherove (author of The Art of Unit Testing) proposed in 2005: Quote_VipCustomer_AppliesFivePercentDiscount. The second writes the behavior as a sentence: Vip_customer_gets_five_percent_off_the_subtotal. Both are far better than Test1; being consistent within the team matters more than which one you pick. The sentence style lets the test list read like a specification:
Passed Bookstore.Tests.OrderPricingTests.Audiobook_is_priced_at_ninety_percent_of_list_price [3 ms]
Passed Bookstore.Tests.OrderPricingTests.Standard_shipping_below_500_depends_on_the_city(city: "Ankara", expectedFee: 39.9) [< 1 ms]
Passed Bookstore.Tests.OrderPricingTests.Standard_shipping_below_500_depends_on_the_city(city: "Istanbul", expectedFee: 29.9) [< 1 ms]
Passed Bookstore.Tests.OrderNotifierTests.Confirmation_goes_to_the_customer_and_states_the_total [5 ms]
Passed Bookstore.Tests.OrderPricingTests.Regular_customer_on_a_weekday_pays_list_price_plus_tax_and_shipping [1 ms]
Passed Bookstore.Tests.OrderPricingTests.Vip_customer_gets_five_percent_off_the_subtotal [< 1 ms]
Passed Bookstore.Tests.OrderPricingTests.Weekend_order_gets_an_extra_two_percent_off [< 1 ms]
Passed Bookstore.Tests.OrderPricingTests.Unknown_coupon_is_rejected [< 1 ms]
Someone from marketing could read this list too. The total run time is 25 milliseconds; the first letter of F.I.R.S.T. is covered.
Cutting Arrange Noise: Test Data Builders and Object Mothers
OrderPricing.Quote takes five parameters and a clock. If every test sets all of them up by hand, the line that tests the VIP discount gets lost in the noise of shipping cities and coupons. There are two well-known solutions. Object Mother (a pattern named at ThoughtWorks and described on Martin Fowler's bliki) is a factory that produces ready-made, named test objects. Test Data Builder spread with a 2007 post by Nat Pryce (one of the two authors of Growing Object-Oriented Software, Guided by Tests): every field has a reasonable default, and the test changes only the value it cares about.
// Object Mother: ready-made, named test books
public static class TestBooks
{
public static Book Dune() => new() { Isbn = "978-0441013593", Title = "Dune", Price = 450m };
public static Book Audiobook(decimal price = 250m) =>
new() { Isbn = "978-0553380163", Title = "A Brief History of Time", Price = price, Format = BookFormat.Audio };
}
// Test Data Builder: the test writes only the value it cares about, the rest are sensible defaults
public sealed class QuoteBuilder
{
private static readonly TimeZoneInfo Istanbul = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul");
private readonly List<CartLine> _lines = [];
private CustomerTier _tier = CustomerTier.Regular;
private string? _coupon;
private ShippingMethod _shipping = ShippingMethod.Standard;
private string _city = "Ankara";
private DateTimeOffset _now = new(2026, 11, 5, 10, 0, 0, TimeSpan.FromHours(3)); // Thursday
public QuoteBuilder With(Book book, int quantity = 1)
{
_lines.Add(new CartLine(book, quantity));
return this;
}
public QuoteBuilder ForVip()
{
_tier = CustomerTier.Vip;
return this;
}
public QuoteBuilder WithCoupon(string code)
{
_coupon = code;
return this;
}
public QuoteBuilder ShippedTo(string city)
{
_city = city;
return this;
}
public QuoteBuilder On(DateTimeOffset now)
{
_now = now;
return this;
}
public PriceQuote Quote()
{
var clock = new FakeTimeProvider(_now);
clock.SetLocalTimeZone(Istanbul);
IReadOnlyList<CartLine> lines = _lines.Count > 0 ? _lines : [new CartLine(TestBooks.Dune(), 1)];
return new OrderPricing(clock).Quote(lines, _tier, _coupon, _shipping, _city);
}
}
The tests now describe the behavior with a one-line arrange:
[Fact]
public void Vip_customer_gets_five_percent_off_the_subtotal()
{
PriceQuote quote = new QuoteBuilder().ForVip().Quote();
Assert.Equal(22.50m, quote.Discount);
}
[Fact]
public void Weekend_order_gets_an_extra_two_percent_off()
{
PriceQuote quote = new QuoteBuilder().On(Saturday).Quote();
Assert.Equal(9m, quote.Discount);
}
[Theory]
[InlineData("Istanbul", 29.90)]
[InlineData("Ankara", 39.90)]
public void Standard_shipping_below_500_depends_on_the_city(string city, decimal expectedFee)
{
PriceQuote quote = new QuoteBuilder().ShippedTo(city).Quote();
Assert.Equal(expectedFee, quote.Shipping);
}
The weekend test now gives the same result every day, because the clock comes from FakeTimeProvider. I chose the builder's defaults on purpose: Thursday, a regular customer, Ankara. That way each test switches on a single rule, and the only explanation for the 9 TRY in Discount is the weekend. Another benefit of the builder is resilience to change: if a sixth parameter is added to Quote tomorrow, only the builder changes, not forty tests.
Readable Asserts: What Do They Say When They Fail?
You find out the quality of an assert not when the test passes but when it breaks. I deliberately wrote the wrong expected discount (25 TRY) in the VIP test and ran it with three different asserts. Assert.True(quote.Discount == 25m):
Assert.True() Failure
Expected: True
Actual: False
Assert.Equal(25m, quote.Discount):
Assert.Equal() Failure: Values differ
Expected: 25
Actual: 22.50
With the whole record, Assert.Equal(new PriceQuote(...), quote):
Assert.Equal() Failure: Values differ
Expected: PriceQuote { Subtotal = 450, Discount = 25, Shipping = 39.90, Tax = 42.50, Total = 507.40 }
Actual: PriceQuote { Subtotal = 450, Discount = 22.50, Shipping = 39.90, Tax = 42.7500, Total = 510.15 }
The first says nothing; whoever sees it in the CI log has to open a debugger. The second gives the value, the third the whole calculation. The value equality and readable ToString() of records work like a free assertion library here. A domain-specific helper that verifies a single total but explains the calculation when it breaks takes only a few lines:
// An assert that speaks the domain's language: on failure it prints the breakdown
public static class PriceAssert
{
public static void TotalIs(decimal expected, PriceQuote quote)
{
if (quote.Total != expected)
Assert.Fail(string.Create(CultureInfo.InvariantCulture,
$"Expected total {expected:0.00} but was {quote.Total:0.00} " +
$"(subtotal {quote.Subtotal:0.00}, discount {quote.Discount:0.00}, shipping {quote.Shipping:0.00}, tax {quote.Tax:0.00})."));
}
}
Expected total 507.40 but was 510.15 (subtotal 450.00, discount 22.50, shipping 39.90, tax 42.75).
The second payoff of helpers like this is the boundary idea from the eighth post. When tests depend on their own helpers rather than directly on an assertion library, a situation like FluentAssertions 8 moving to a commercial license in January 2025 becomes a small migration; switching to Shouldly or the community fork AwesomeAssertions only means changing the helpers.
Hidden Dependencies: The Clock, Static State and Over-Mocking
Both things that broke the legacy tests were invisible dependencies: DateTime.Now and the static Db. The fix is the same for both: take the dependency through the constructor. For the clock, the BCL (Base Class Library) has had TimeProvider since .NET 8; production uses TimeProvider.System, tests use FakeTimeProvider from the Microsoft.Extensions.TimeProvider.Testing package. FakeTimeProvider also supports moving time forward (Advance) and setting the local time zone; the SetLocalTimeZone call in the builder makes the weekend rule evaluate in Istanbul time rather than UTC (Coordinated Universal Time). The details of designing for time and time zones are in the Solution Patterns series post "Time and Time Zones: UTC Instants, Local Business Dates, DateOnly and TimeProvider".
Taking dependencies from outside brings a danger as well: mocking everything. Two tests for OrderNotifier:
// Brittle: pins down every space and line break in the email text
[Fact]
public async Task SendConfirmationAsync_WhenCalled_CallsSendAsyncWithExactBody()
{
var sender = new Mock<IEmailSender>();
var notifier = new OrderNotifier(sender.Object);
await notifier.SendConfirmationAsync(Order);
sender.Verify(s => s.SendAsync(
It.Is<EmailMessage>(m => m.Body == "Dear Ada Lovelace,\nYour order #1000 has been received.\n - Dune x1 = 450.00\nShipping: 39.90\nTotal: 534.90 TRY\n"),
It.IsAny<CancellationToken>()), Times.Once);
}
// Tests behavior: to whom, which subject, which total
[Fact]
public async Task Confirmation_goes_to_the_customer_and_states_the_total()
{
var sender = new RecordingEmailSender();
await new OrderNotifier(sender).SendConfirmationAsync(Order);
EmailMessage mail = Assert.Single(sender.Sent);
Assert.Equal("ada@example.com", mail.To);
Assert.Equal("Order #1000", mail.Subject);
Assert.Contains("Total: 534.90 TRY", mail.Body);
}
Both pass on this machine. But the first breaks as soon as a space is added to the text, and, more insidiously, it will break on Windows too: StringBuilder.AppendLine uses Environment.NewLine as the line ending, which is \r\n on Windows. The test pins down the exact text of the implementation, not the behavior. The second test uses a small hand-written fake (RecordingEmailSender) and verifies only what matters. The rule: use a mock when the interaction with the outside world is itself what matters (was the payment charged exactly once?); when you can check the outcome, look at state. The technical details of xUnit, Moq and the kinds of test doubles are in the C# Advanced series post "Unit Testing in C#: xUnit, Theory/InlineData and Testable Code" and the ASP.NET Core Web API (Application Programming Interface) series post "Unit Testing an ASP.NET Core Web API: Service and Controller Tests with xUnit and Moq".
Common Pitfalls
An if or a loop inside a test. If you walk through five shipping cities with a foreach in a single test, the message won't say which city broke, and after the first failure the rest never run. Make each case a separate test with [InlineData]; the city test above works that way.
Using reflection to test private methods, or making them internal and opening them with InternalsVisibleTo. OrderPricing.UnitPrice is private and doesn't need its own test; Audiobook_is_priced_at_ninety_percent_of_list_price already verifies it through the public Quote. Needing to test a private method separately is usually a sign that it belongs in a separate class.
A shared, mutable fixture. The legacy tests' static Db problem also shows up when you keep a static builder or list in a test class. xUnit creates a new class instance for every test; keep state in instance fields.
Tying the whole output to a snapshot. Tests that compare an entire JSON (JavaScript Object Notation) response or email text verbatim break on every formatting change, like the mock example above, and the team updates them without thinking. If the snapshot really is the contract, for example the response of a public API, it makes sense; otherwise verify the important fields individually.
When Not to Use It
Don't write a builder for a two-field record. new CartLine(TestBooks.Dune(), 2) is already readable; a builder pays off for setups with many parameters, most of which are irrelevant to the test. For records, the with expression also works like a lightweight builder: baseline with { Quantity = 3 }.
Don't write a domain-specific helper for every assert. PriceAssert.TotalIs exists because the total is verified in many tests and the breakdown on failure genuinely helps. A helper used in a single test just makes the reader open one more file.
Don't try to apply this post's rules to characterization tests from the start. Tests that pin down the behavior of legacy code before you change it can be as dirty as the code itself; their purpose is to be a safety net. In the fourteenth post, while cleaning up OrderService, we'll start with tests like that and move them step by step toward the form shown in this post.
A Small Experiment
★☆☆ Rewrite the legacy Test1 and Test2 with F.I.R.S.T. in mind: use Db.Reset() and Db.Seed() so that each test sets up its own state. Can you solve the DateTime.Now problem without changing the legacy code? If you can't, what does that tell you about the code?
★★☆ Add WithLines(int count) to QuoteBuilder and test the boundary values (5 and 6 lines) of the "15 TRY off for more than five lines" rule. Write the test names in the sentence style.
★★★ Turn the brittle Moq test for OrderNotifier into one that uses RecordingEmailSender and doesn't depend on Environment.NewLine; then write the same test with FluentAssertions 7, Shouldly and plain xUnit asserts, and compare the messages they give when they fail.
With this post, the class and test side of Block B is complete. The next post looks at the four rules of simple design from Kent Beck (the creator of Extreme Programming and a pioneer of Test-Driven Development): passes the tests, reveals intention, no duplication, fewest elements. We'll see why the order matters by growing a small discount feature step by step.