Course outline · 0% complete

0/29 lessons0%

Course overview →

Finding the Cases You Forgot

lesson 3-1 · ~10 min · 7/29

One act per test

Lesson 2-1's arrange, act, assert pattern calls for exactly one call to the function under test in the act phase.

One act per test keeps the test about a single behavior, which is the rule from lesson 2-2. If the test fails you know precisely which call broke, and the name can honestly describe one fact rather than hedging over several.

The edge cases in this unit multiply the number of tests you write, which is why the one-act discipline matters more here than anywhere. Six checklist items become six small tests rather than one test with six calls in it.

The edge-case checklist

Most bugs do not live in the typical case, they live at the edges: inputs the author never pictured. You can hunt them systematically instead of hoping. For any function, walk this checklist:

  • zero / empty: 0, "", [], {}
  • one: a single element or character
  • many: a normal, busy input
  • extremes: the largest and smallest sensible values, negatives
  • duplicates and ties: two items that compete for the same answer
  • weird but legal: extra spaces, mixed case, unicode

Each checklist line becomes one focused test, named the way lesson 2-2 taught. The habit takes two minutes per function and catches a shocking share of real-world crashes.

typical inputs where most tests are written lower edge zero, empty one element negatives upper edge very large duplicates, ties weird but legal bugs cluster where the author stopped imagining walk the checklist, one test per line that applies gold boxes: the probes worth adding first
The edge-case checklist. Typical inputs sit in the middle, and bugs cluster at the edges around them.

One test per checklist line

longest_word gets a test for each checklist item that applies, namely many, one, empty, and a tie.

def longest_word(words):
    best = ""
    for w in words:
        if len(w) > len(best):
            best = w
    return best

def test_many_words():
    assert longest_word(["hi", "hello", "hey"]) == "hello"

def test_one_word():
    assert longest_word(["solo"]) == "solo"

def test_empty_list():
    assert longest_word([]) == ""

def test_tie_keeps_first():
    assert longest_word(["aaa", "bbb"]) == "aaa"

for test in [test_many_words, test_one_word, test_empty_list, test_tie_keeps_first]:
    test()
    print("PASS", test.__name__)

Output

PASS test_many_words
PASS test_one_word
PASS test_empty_list
PASS test_tie_keeps_first

The tie test is the most valuable of the four, because it pins down a decision the code makes only by accident. The comparison is > rather than >=, so the first of two equal-length words wins, and a later refactor that flipped it to >= would silently change the answer. This test is the difference between defined behavior and a coincidence.

The empty-list test passes because best starts as "" and the loop never runs. That is worth noticing rather than assuming, since an implementation written as best = words[0] would crash on the same input.

Each test name states its situation, so the four names read as a summary of what the function guarantees. That is the naming rule from lesson 2-2 applied to a whole checklist.

The checklist item most likely to crash

For find_cheapest(prices), the input most likely to break a naive implementation is the empty one.

Empty input is the classic killer because so many natural implementations assume at least one element exists:

Implementation detailWhat [] does to it
min(prices)raises ValueError
sum(prices) / len(prices)raises ZeroDivisionError
best = prices[0]raises IndexError
best = None then a loopreturns None, silently

The first three fail loudly, which is unpleasant but at least visible. The fourth is worse, since a None flows onward and crashes somewhere else entirely, far from the cause.

The habit worth building is to write the empty-input test first, before the typical case, and to decide on purpose what should happen. Returning 0, returning None, and raising an error are all defensible answers, and the point is that the choice gets made deliberately and written down rather than falling out of whichever loop shape you happened to type.

Guarding the empty case

The team decided safe_average([]) should return 0.0, and a test exists for it. Before the guard, the function raised ZeroDivisionError, and two added lines fix it.

def safe_average(nums):
    if not nums:
        return 0.0
    return sum(nums) / len(nums)

def test_typical():
    assert safe_average([2, 4, 6]) == 4.0

def test_empty_returns_zero():
    assert safe_average([]) == 0.0

for test in [test_typical, test_empty_returns_zero]:
    test()
    print("PASS", test.__name__)

Output

PASS test_typical
PASS test_empty_returns_zero

len([]) is 0 and dividing by zero raises, so the guard has to come before any division happens. Putting the check after the division would be too late, which sounds obvious and is a real mistake when the division is buried a few lines down.

if not nums: is the idiomatic empty test in Python, and it is true for [], "", and None alike. Writing if len(nums) == 0: is equally correct for a list and would itself crash on None, so the shorter form is also the more forgiving one.

The returned value is 0.0 rather than 0, matching the float that the division produces in the normal case. Returning an int from one branch and a float from the other works in Python and is the kind of small inconsistency that trips up code downstream.

Worth noting: this only counts as correct because the team decided on 0.0. A different team could reasonably require a raised error, and the test is what records which decision was made.

Walking the checklist unaided

Four tests for total_length, one for each of the checklist lines that apply: many, one, empty, and weird-but-legal.

def total_length(words):
    return sum(len(w) for w in words)

def test_many_words():
    assert total_length(["hi", "there"]) == 7

def test_one_word():
    assert total_length(["solo"]) == 4

def test_empty_list():
    assert total_length([]) == 0

def test_space_padding_counts():
    assert total_length([" a "]) == 3

for test in [test_many_words, test_one_word, test_empty_list, test_space_padding_counts]:
    test()
    print("PASS", test.__name__)

Output

PASS test_many_words
PASS test_one_word
PASS test_empty_list
PASS test_space_padding_counts

The many case asserts 7 because "hi" has length 2 and "there" has length 5. Working the expected value out by hand, rather than from the code, is what keeps the test independent of the implementation.

The padding case is the interesting one. " a " is three characters, being space, a, space, so the total is 3. Whether that is wanted is precisely the conversation this test forces, and either answer is fine as long as somebody chose it.

That is the real value of the weird-but-legal line on the checklist. It rarely finds a crash, and it frequently finds an unmade decision, which is the kind of gap that turns into a bug report months later when real user data arrives with trailing spaces in it.

The empty case passes for free here, since sum() of an empty generator is 0, so no guard is needed. Confirming that with a test is still worthwhile, because it locks the behavior in place against a future rewrite that reaches for words[0].