Unit Testing Best Practices: A Practical Guide
- Shawn West
- Mar 6
- 9 min read
Updated: Aug 10
A single green test approved a pricing bug straight into production — not because testing failed, but because the test verified the implementation instead of the behavior. Here is the mechanism behind why it lied, and how every unit-testing best practice earns its place on one real function: a discount that overcharged nobody and undercharged the company.
The pull request looked finished. A premium-discount feature, a passing test named test_premium_discount, a green check on the CI run. The reviewer saw green and approved. Two weeks later, finance flagged it: premium customers on large orders were being under-charged, and the company had quietly given away more discount than its own policy allowed. The code had a bug. The test had passed anyway. Worse — the test had passed because of how it was written, not despite it.
Here is the function that shipped. The business rule, which had been settled in the discovery conversation with finance, was: premium customers get 10% off the subtotal, and the discount is capped at $50 so no single order gives away more than that.
def order_total(subtotal, is_premium):
if is_premium:
return subtotal - subtotal * 0.10
return subtotal
Notice what is missing: the cap. Now here is the test that approved it.
def test_premium_discount():
subtotal = 200
expected = subtotal - subtotal * 0.10
assert order_total(200, is_premium=True) == expected
It passes. It will always pass. And it is one of the most common mistakes in a real test suite.
Why the green test lied
Look at the expected value. It is computed with the exact same expression the function uses: subtotal - subtotal * 0.10. The assertion is not checking that the code produces the right answer — it is checking that the code equals a copy of itself. A test written this way cannot fail for a wrong business rule, because it never encoded the business rule. It encoded the implementation.
That is the difference the whole discipline turns on. Testing behavior means writing down what the code should produce, from the specification, as a value you worked out independently. Testing implementation means asserting on how the code arrives there — recomputing its formula, checking which internal method it called, reaching into its private state. The first survives an honest refactor and fails on a real defect. The second does the reverse: it breaks when you rename a variable and stays green when the answer is wrong.
There is a second reason the reviewer was fooled. That one test gives the function 100% line coverage — every line runs. The coverage tool reported the function fully covered, and it was, in the only sense coverage can measure: every line executed at least once. Coverage measures which lines ran, not whether the assertions that ran were worth anything. A line can execute under a test that could never have caught its bug. (If you have never watched a two-line test catch a real defect, start there first — this piece assumes you have written at least one.)
The mechanism, stated plainly: an assertion copied from the code is a mirror. It reflects whatever the code does back at the code, and reports a match. The only way to break the mirror is to compute the expected value from somewhere the code cannot reach — the specification.
Write the assertion from the spec, not the code
The fix starts before any refactor. Write the test the way a person who knew the policy but not the code would write it — as a hand-computed number, and specifically at the boundary where the rule bites.
def test_premium_discount_is_capped_at_50():
# $1,000 order: 10% is $100, but policy caps the discount at $50,
# so the customer pays $950.
assert order_total(1000, is_premium=True) == 950
Run it against the shipped code and it fails: it returns 900, not 950. The $1,000 order is exactly the case the mirror test could never see. On a $200 order the cap does not bind — 10% is $20, comfortably under $50 — so the buggy code and the correct code agree, and any test at that size passes for both. The defect only appears past the boundary, and the original test never went there.
This is the discovery-first part, and it is worth naming precisely, because it is where the bug actually came from. The $50 cap is a rule that lives in the specification, not in the code's shape. A test derived from the code can only ever know what the code knows — and the code did not know about the cap. The intake question that would have caught this was one line in the discovery conversation: "Is there a maximum discount?" Ask it, and you write the boundary test, and the test fails, and the bug never ships. Skip it, and no amount of testing the implementation recovers the rule, because the rule was never in the implementation to test. The failure wasn't a coding mistake wearing a testing costume; it was an unvalidated data assumption — that discount scales without limit — that both the code and its test inherited unquestioned.
Now the refactor is trivial, because the test tells you exactly what "done" means:
def order_total(subtotal, is_premium):
if not is_premium:
return subtotal
discount = min(subtotal * 0.10, 50)
return subtotal - discount
The capped test passes. Keep the small-order test too — order_total(200, is_premium=True) == 180 is still correct behavior, and now it guards the un-capped path. Two tests, two named numbers, both derived from the policy. Neither is a mirror.
The diagnostic: does the test break when the behavior doesn't?
You will not always spot a mirror test by reading it. There is a faster, mechanical check you can run on any test you suspect — and it is the single most useful signal for whether a test is coupled to implementation:
Refactor the code without changing its behavior. If a test breaks, that test was testing the wrong thing.
Make the change harmless on purpose. Rename subtotal to order_subtotal. Extract the cap into a named constant. Replace min(subtotal * 0.10, 50) with an if that computes the same result. The customer is charged the identical amount in every case. A behavior test — == 950, == 180 — does not even notice; it asserts on the output, and the output did not move. A test that had asserted on the how would break:
def test_premium_discount_calls_min():
with patch("pricing.min") as mock_min:
order_total(1000, is_premium=True)
mock_min.assert_called_once() # breaks the moment you refactor to an if
That test verifies the code used min(). Swap min for an equivalent if and it fails — while the customer's bill is unchanged. It is coupled to a decision that was never part of the promise. This is the same trap that makes over-mocked tests brittle: a mock that asserts which collaborator was called, in what order, freezes the implementation in place and fails on every honest refactor. (When a double is genuinely the right call and when it is coupling you into a corner is its own decision — mocking, stubbing, and faking each answer a different question.)
Run the refactor test on your own suite this week. Pick a module, make a behavior-preserving change, and run the tests. Every test that goes red is a test telling you it was watching the implementation, not the behavior. That red set is your brittleness inventory — and it is usually the exact set that fails to catch real bugs, for the same reason.
Arrange-Act-Assert, and why the order is load-bearing
The two good tests already follow the pattern that keeps tests readable under pressure. It has three parts, and the sequence is not decoration:
def test_premium_discount_is_capped_at_50():
subtotal, is_premium = 1000, True # Arrange: state the inputs
total = order_total(subtotal, is_premium) # Act: one call, the behavior under test
assert total == 950 # Assert: the spec's number
Arrange sets up the inputs and preconditions. Act invokes the behavior — one call, the thing the test is about. Assert checks the result against a value you derived from the spec. The reason to keep them separate and in order is diagnostic: when this test fails, the failure line points at the assert, the single call above it is the entire behavior under test, and the setup is right there to inspect. A test that interleaves setup and assertions — compute a little, assert, compute more, assert again — forces you to reconstruct which state produced which failure. The structure is not style; it is what makes a red test tell you where it broke without opening a debugger.
One assert per concept — not one assert per test
The rule is widely quoted as "one assertion per test" and widely misapplied. The real rule is one concept per test. Asserting three things about the same behavior is fine; asserting on two unrelated behaviors in one test is the problem, because when it fails you cannot tell which behavior broke.
On our function, that boundary is clear. The capped discount and the standard-customer path are different concepts and belong in different tests:
def test_premium_discount_is_capped_at_50():
assert order_total(1000, is_premium=True) == 950
def test_standard_customer_pays_full_price():
assert order_total(1000, is_premium=False) == 1000
Two concepts, two tests, two names that will appear in the failure output. If you had jammed both asserts into one test_order_total, a regression in the cap and a regression in the standard path would produce the same red line, and you would learn less from the failure than the test could have told you.
Name the test so the failure explains itself
test_premium_discount — the name from the original bug — describes a feature, not a behavior. When it failed (if it ever had), the runner would print test_premium_discount FAILED and you would learn nothing. Compare test_premium_discount_is_capped_at_50 FAILED. That line, with no source open, tells you which rule broke. A good test name is the first line of the failure report, and it should read as given this input, the behavior is this. Name for the behavior and the boundary, not the function under test.
Keep it deterministic, keep it isolated
Our function is easy to test because it is pure: same inputs, same output, no clock, no database, no shared state. That is not an accident of the example — it is the property that makes a test trustworthy. A unit test that reads date.today(), hits a network, or leaves state behind for the next test is a test that can go red without a code change, and a test that goes red at random trains the team to ignore red. When a unit needs the current time or an external value, pass it in as an argument the way a spec would — the same discipline that lets order_total take its inputs directly. (Time, async, and external state are the hard cases; they have their own techniques worth learning deliberately.)
Isolation is the same principle across tests: each test arranges the state it needs and shares no mutable state with any other, so the suite produces the same result in any order and in parallel. If reordering your tests changes the outcome, you have a hidden dependency, and it will surface as a flake on the worst possible day.
Coverage finds gaps; it cannot certify behavior
Return to where this started: the buggy function had 100% line coverage and a passing suite. Coverage told the truth about lines and nothing about correctness. Use it for what it is good at — a diagnostic that points at code no test exercises, which is a real and useful signal. Do not use it as a finish line, because the number cannot see the difference between an assertion derived from the spec and a mirror that reflects the code back at itself. "The important behavior is tested at its boundaries" is the target; "the coverage number is high" is a number you can hit while shipping the exact bug in this article.
Here is the whole distinction on one page:
Practice | Implementation-coupled version (brittle) | Behavior version (durable) | The test that proves it |
Assertion | expected = subtotal - subtotal*0.10 (copies the code) | == 950 (from the spec, at the cap) | Fails on the boundary the code missed |
Verifying a call | mock_min.assert_called_once() | assert on the returned total | Refactor min→if; behavior test stays green |
Coverage | 100% lines, bug shipped | boundary case exercised | The un-capped case has no test |
Naming | test_premium_discount | test_premium_discount_is_capped_at_50 | Failure line names the broken rule |
Every row is the same move: assert on what the reader of the spec would check, never on what the author of the code happened to write. Unit tests are the base of the pyramid precisely because, done this way, they are fast and precise enough to pin one behavior at a time — which is a different job from integration and end-to-end tests, and worth keeping distinct.
The habit to run this week
Open your test suite and find one test whose expected value is computed with the same expression the code under test uses. There is almost always one. Rewrite its expected value as a number you work out by hand from the specification — and add the case at the boundary, the largest or smallest or edge input where the rule actually changes behavior. If that new test passes immediately, good; you have replaced a mirror with a real assertion. If it fails, you have just found your order_total — a bug that a green check was hiding — before finance does.
Then make it a reflex on your next bug fix: write the failing test as a hand-computed number from the spec, watch it go red, fix the code, watch it go green. The test that catches a bug once, written from the behavior rather than the implementation, is the test that keeps catching it through every refactor for the life of the code.
Related reading
Keep learning. This article is part of the Software Testing Foundations path in the ShiftQuality Learning Center. Learn to design tests that catch real bugs.


