Unit testing best practices: test behavior, not implementation
There is a moment every engineer on a mature codebase recognizes: the build goes red, and instead of asking what broke, someone re-runs it. The suite takes 40 minutes, fails once a day for reasons nobody can reproduce, and has trained the whole team to treat red as noise. That suite is not an asset with a maintenance problem. It is a liability that happens to execute assertions. None of it gets fixed by a faster build machine, because the causes are design decisions inside the tests themselves. The good news is that they are decisions, which means they can be remade.
The shape of the suite decides how fast you learn
The healthy pyramid
e2e
~5 tests, 4 min
integration
~40 tests, 90 s
unit
~400 tests, 6 s
full run: about 6 min
The ice-cream cone
e2e
~180 tests, 32 min
integration
~25 tests, 8 min
unit
~12 tests, 1 s
- feedback arrives 40 minutes after the change that caused it
- a network blip can fail any test, so red stopped meaning broken
- failures point at the whole system instead of a class
The test pyramid is old advice that keeps being right: a wide base of fast, isolated unit tests, a middle band of integration tests that exercise real seams, and a handful of end-to-end flows across the top. The reasoning is not aesthetic. It is about feedback latency and failure localization. A failing unit test names the exact class and runs in milliseconds; a failing end-to-end test names "something, somewhere" and takes minutes to reproduce even once.
The failure mode has a name too: the inverted pyramid, better known as the ice-cream cone. A thin unit base, and everything pushed up into slow end-to-end tests because "that is what tests the real thing." The real thing then gets tested 40 minutes at a time, through a network that is allowed to hiccup, and every failure opens a murder investigation with the whole system as a suspect. The repair rule is mechanical: push every check down to the cheapest layer that can catch that failure. Business logic belongs in unit tests. The query and the mapping belong in an integration test. The end-to-end tier is for proving the pieces are wired together, a job that needs five flows, not one hundred and eighty.
A test is a contract about behavior
The deeper habit that decides whether a suite ages well is what the tests actually assert.
A good test fails for exactly one reason: the behavior changed. Every other failure is a tax on the refactoring work the test was supposed to protect.
Here is the anti-pattern, a test that mirrors the implementation instead of stating a contract:
[Fact]
public void ApplyDiscount_CallsCalculatorAndSaves()
{
_calculator.Setup(c => c.Calculate(It.IsAny<Order>())).Returns(2_000m);
_service.ApplyDiscount(_order);
_calculator.Verify(c => c.Calculate(_order), Times.Once);
_repository.Verify(r => r.Save(_order), Times.Once);
}
Inline the calculator, batch the saves, reorder the calls: the behavior is identical and this test fails anyway. It has pinned the choreography, not the outcome. Compare the contract version:
[Fact]
public void ApplyDiscount_OrderOverThreshold_ReducesTotalByTenPercent()
{
var order = new Order(totalCents: 20_000);
_service.ApplyDiscount(order);
Assert.Equal(18_000, order.TotalCents);
}
This one survives any refactoring that preserves the promise, and fails for exactly one reason: the promise broke. The corollary is where mocks belong. Mock at role boundaries, the ports where your code meets the world it cannot control, such as the clock or a payment gateway that costs real money to call. Let ordinary collaborators be real objects. A test that needs five mocks to compile is not thorough; it is a description of the current call graph, and your design is telling you the unit under test has too many dependencies.
A related trap is treating "unit" as a synonym for "class." The unit worth isolating is a behavior, and a behavior often lives in a small cluster of objects that only make sense together. Driving an order and its line items through the order's public surface is one unit test, not a shortcut. Wrapping every class in an interface so each can be mocked individually produces suites that know everything about the structure and nothing about the promises.
Arrange, Act, Assert, and a name that states the behavior
Both tests above follow the AAA pattern: arrange the world, act once, assert on the outcome. The
structure earns its keep by making the act line easy to find, because a test where the action
is smeared across ten lines of setup is a test nobody can review. Pair it with a naming
convention that states the behavior, and pick one convention per codebase rather than three. The
given-when-then flavor expresses the same discipline with different punctuation; the
MethodName_Scenario_Outcome form used here works well with xUnit:
[Fact]
public void Withdraw_AmountExceedsBalance_ThrowsInsufficientFunds()
{
// Arrange
var account = new Account(balanceCents: 10_000);
// Act
var withdraw = () => account.Withdraw(amountCents: 15_000);
// Assert
Assert.Throws<InsufficientFundsException>(withdraw);
Assert.Equal(10_000, account.BalanceCents);
}
The payoff shows up in the failure list: a reader can reconstruct the specification from test names alone, without opening a single file.
Notice the test asserts two things and is still one test. The old "one assert per test" dogma mistakes the mechanism for the goal. The rule that matters is one behavior per test, and the behavior here is a single promise: a failed withdrawal changes nothing. Two assertions describing one outcome belong together. Two behaviors sharing one test body should be split, because when the test fails you should know which promise broke without reading a diff.
Determinism is the cure for flaky
A flaky test is worse than no test, because it still costs run time while paying out distrust.
Flakiness is not weather; it comes from a short list of suspects. The first is real time: code
that reads the system clock produces tests that fail at midnight, at month ends, and during
daylight saving transitions, and the fix is to inject the clock (TimeProvider in modern .NET)
so tests can hold time still. An invoice-overdue check that reads DateTime.UtcNow directly can
never be pinned to a known answer; one that takes a TimeProvider can be handed a frozen clock
and asserted exactly. The second suspect is shared state: static caches, a database seeded by a
previous test, files on disk. Tests that pass alone and fail in parallel are telling you they
share a world, and each test should build its own. The third is ordering: test B quietly depends
on test A having run first. Randomized and parallel execution surfaces this early, which is a
feature; the fix is the same fresh-world discipline. Determinism is not a nice-to-have on top of
a good suite. It is the property that makes red mean broken, which is the entire value of the
color.
Coverage is a floor, not a goal
Coverage tells you which lines executed under test. It says nothing about whether any assertion would notice a wrong answer on those lines, and a suite optimized for the number will happily walk code while asserting nothing. Treat coverage as a smoke detector for whole modules nobody tests, not as a score to maximize. If you want a number worth watching, watch coverage on new code in each change: a falling floor there means the team stopped writing tests, while a rising total mostly means someone was told to raise it. And even honest coverage measures only correctness on the happy path: a fully covered function can still allocate wildly, melt under load, or fall over on adversarial input, which is what green checkmarks systematically fail to measure. Tests are one lens on quality; the structural lens, what the code's own shape says about it, is a separate measurement with its own failure modes.
Practicing the judgment
The mechanics of xUnit take an afternoon. The slow part is judgment: which behaviors deserve a contract, which collaborators should be real, which layer should catch which failure. That judgment builds the way every engineering skill builds, through repetitions with honest feedback. Katabench's practice tracks include test-writing exercises graded the way everything on the platform is graded: by outcome, because the grading model awards nothing for effort or for green alone. A suite earns its grade by what it catches. That is the standard worth internalizing, since it is the one production applies to your tests every day, silently, whether or not anyone is measuring.