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.
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 detail | What [] does to it |
|---|---|
min(prices) | raises ValueError |
sum(prices) / len(prices) | raises ZeroDivisionError |
best = prices[0] | raises IndexError |
best = None then a loop | returns 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].