Course outline · 0% complete

0/29 lessons0%

Course overview →

One Behavior per Test, Honest Names

lesson 2-2 · ~10 min · 5/29

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_everything sends you digging. FAIL test_zero_weight_rejected is 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:

NameProblem
test_case_3says nothing at all, forces you to open the file
test_checkout_worksclaims territory no single test can cover honestly
test_couponnames the subject but not the behavior or the expectation
test_expired_coupon_is_rejectedstates 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.