One test, one fact
A tempting habit: cram every assert into one big test_everything. Two problems.
- Remember from lesson 1-3 that a test function stops at its first failed assert. In a mega-test, one failure hides the status of everything after it.
- The name tells you nothing.
FAIL test_everythingsends you digging.FAIL test_zero_weight_rejectedis already half the diagnosis.
So the rule: one behavior per test, named as a sentence about that behavior. A good pattern is test_<situation>_<expected result>, like test_heavy_parcel_adds_per_kg. If you cannot name the test in one short sentence, it is probably testing two things. Split it.
Three behaviors, three tests
shipping_cost has three distinct behaviors, so it gets three focused tests, each named for the fact it checks.
def shipping_cost(weight_kg): if weight_kg <= 0: raise ValueError("weight must be positive") if weight_kg <= 1: return 5.0 return 5.0 + (weight_kg - 1) * 2.0 def test_light_parcel_flat_rate(): assert shipping_cost(0.5) == 5.0 def test_heavy_parcel_adds_per_kg(): assert shipping_cost(3) == 9.0 def test_zero_weight_rejected(): try: shipping_cost(0) assert False except ValueError: pass for test in [test_light_parcel_flat_rate, test_heavy_parcel_adds_per_kg, test_zero_weight_rejected]: test() print("PASS", test.__name__)
Output
PASS test_light_parcel_flat_rate PASS test_heavy_parcel_adds_per_kg PASS test_zero_weight_rejected
The third test shows how to check that a function raises. Call it, and if no ValueError arrives, the next line runs and assert False forces a failure. The except ValueError: pass branch is the success path, which reads backwards at first and becomes natural quickly.
The assert False line is essential, and leaving it out is a classic mistake. Without it, a shipping_cost that quietly returned 0 instead of raising would make the test pass, since nothing would have gone wrong. Real frameworks provide pytest.raises for exactly this, and it exists because the hand-rolled version is easy to get wrong.
Reading the three names in order describes the whole pricing rule, which is the documentation benefit of behavior naming. The expected 9.0 comes from 5.0 plus two extra kilos at 2.0 each, computed by hand rather than from the code.
What a good failure name tells you
A suite reporting FAIL test_negative_balance_blocks_withdrawal tells you exactly which behavior broke before you open any code. Withdrawals are no longer being blocked when the balance is negative.
A behavior-named test turns a red mark into a plain sentence about what stopped being true. That is the whole reason the naming rule matters, since the failure report becomes readable prose and reading prose is much faster than reading code to reconstruct intent.
The practical payoff shows up when several tests fail at once. A list of behavior names is a description of the damage, and the pattern across them often identifies the cause immediately:
FAIL test_negative_balance_blocks_withdrawal FAIL test_zero_balance_blocks_withdrawal PASS test_positive_balance_allows_withdrawal
Those three lines say the guard on the low side is gone while the happy path still works, which points straight at one comparison. The same information arriving as FAIL test_1 and FAIL test_2 would tell you nothing at all.
Splitting a mega-test into three
A single test_everything bundling three behaviors of clamp becomes three named tests, run through the loop from lesson 1-3.
def clamp(value, low, high): return max(low, min(high, value)) def test_inside_range_unchanged(): assert clamp(5, 0, 10) == 5 def test_below_range_clamps_to_low(): assert clamp(-3, 0, 10) == 0 def test_above_range_clamps_to_high(): assert clamp(99, 0, 10) == 10 for test in [test_inside_range_unchanged, test_below_range_clamps_to_low, test_above_range_clamps_to_high]: test() print("PASS", test.__name__)
Output
PASS test_inside_range_unchanged PASS test_below_range_clamps_to_low PASS test_above_range_clamps_to_high
Each new test keeps exactly one of the three original asserts, and no assertion was added or changed. The split is a pure reorganization, so the coverage is identical and only the reporting improves.
The improvement is concrete. In the bundled version, a clamp that ignored its low bound would fail on the second assert and never reach the third, so you would not know whether the upper bound still worked. Split apart, that same bug produces one FAIL and two PASS lines.
The loop is the runner from lesson 1-3 with the try and except removed, which is fine here because all three pass. Adding a bug to clamp would crash the loop at the first failure, which is a good way to feel why the except clause was worth building.
Choosing a name that still helps in six months
For a test proving that expired coupons are not applied at checkout, the name that earns its keep is test_expired_coupon_is_rejected.
It states one behavior as a sentence, so the failure report alone tells the reader what stopped being true, with no need to open the file. That is the property to optimize for, because the report is what you read first and often all you read.
The alternatives fail in different ways:
| Name | Problem |
|---|---|
test_case_3 | says nothing at all, forces you to open the file |
test_checkout_works | claims territory no single test can cover honestly |
test_coupon | names the subject but not the behavior or the expectation |
test_expired_coupon_is_rejected | states situation and expected result |
The middle one is the most tempting and the most harmful. A broad name invites future contributors to add unrelated asserts to it, which recreates the mega-test problem, and a red run still sends you digging because the name covers half the feature.
The pattern to reach for is situation plus expected result. If a name in that shape comes out awkwardly long, that is usually a signal the test is checking two things and should be split.