Course outline · 0% complete

0/29 lessons0%

Course overview →

Table-Driven Tests

lesson 3-3 · ~10 min · 9/29

One loop, many cases

By now each behavior wants several probes, and writing a full test function per probe gets repetitive. The table-driven pattern fixes that with tools you have had since Python for Beginners: a list of tuples and a loop.

  1. build a cases list of (input, expected) tuples
  2. loop over it
  3. assert with a message that names the failing case, lesson 2-3 style

Adding a new edge case is now a one-line change to the table, so the checklist from lesson 3-1 becomes cheap to apply exhaustively. Professional frameworks have the same idea built in, for example @pytest.mark.parametrize, which turns each table row into its own reported test.

A table-driven suite for sign

Five probes, one loop, and an assert message that names the exact failing case if one ever breaks.

The cases list is the whole test plan, readable at a glance: two negatives, zero, and two positives. The loop below it never changes, no matter how many rows the table grows to. Note that the message interpolates both n and got, so a failure report tells you which input broke and what it produced, not just that something broke.

def sign(n):
    if n < 0:
        return -1
    if n > 0:
        return 1
    return 0

cases = [
    (-5, -1),
    (-1, -1),
    (0, 0),
    (1, 1),
    (99, 1),
]

for n, expected in cases:
    got = sign(n)
    assert got == expected, f"sign({n}) returned {got}, expected {expected}"
    print(f"PASS sign({n}) == {expected}")
print("5 cases passed")

Output

PASS sign(-5) == -1
PASS sign(-1) == -1
PASS sign(0) == 0
PASS sign(1) == 1
PASS sign(99) == 1
5 cases passed

What a new case costs

With a table-driven suite of 12 cases, adding a newly discovered edge case costs one new tuple in the cases list.

That cheapness is the whole point. When a probe costs one line, you actually write the empty, boundary, and weird-but-legal cases from lesson 3-1 instead of talking yourself out of them.

What it does not cost is a whole new test function with its own arrange, act, and assert phases, or a second copy of the loop. That is exactly the duplication the pattern removes. And it is not free either: a table test never discovers cases on its own. The checklist thinking stays yours, the table only makes acting on it cheap.

A table row that catches a real bug

This is the payoff of a cheap third row. The first two cases pass, and the third, a name with a double space, crashes initials with an IndexError.

Here is the version with the bug:

def initials(name):
    parts = name.split(" ")
    return "".join(p[0].upper() for p in parts)

cases = [
    ("Ada Lovelace", "AL"),
    ("grace hopper", "GH"),
    ("Ada  Lovelace", "AL"),
]

for name, expected in cases:
    got = initials(name)
    assert got == expected, f"initials({name!r}) returned {got!r}, expected {expected!r}"
    print(f"PASS initials({name!r}) == {expected!r}")
print("3 cases passed")

"Ada Lovelace".split(" ") splits on every single space, so the double space yields an empty string between the two names, giving ["Ada", "", "Lovelace"]. Then p[0] on that empty string raises IndexError: string index out of range.

The fix is one character pair. Called with no argument, split() treats any run of whitespace as a single separator and drops the empty pieces, giving ["Ada", "Lovelace"]:

def initials(name):
    parts = name.split()
    return "".join(p[0].upper() for p in parts)

cases = [
    ("Ada Lovelace", "AL"),
    ("grace hopper", "GH"),
    ("Ada  Lovelace", "AL"),
]

for name, expected in cases:
    got = initials(name)
    assert got == expected, f"initials({name!r}) returned {got!r}, expected {expected!r}"
    print(f"PASS initials({name!r}) == {expected!r}")
print("3 cases passed")

Output

PASS initials('Ada Lovelace') == 'AL'
PASS initials('grace hopper') == 'GH'
PASS initials('Ada  Lovelace') == 'AL'
3 cases passed

Note that this failure arrives as a crash rather than a failed assert, so the assert message never gets a chance to print. That is why the lesson 1-3 runner needed its second except clause, and it is also why the !r formatting matters here: a double space is invisible in bare output and obvious inside quotes.

The same whitespace distinction appeared in lesson 2-1's word_count, where split() with no argument was the behavior being locked down. Meeting it twice from opposite directions is the point, since once as a feature and once as a bug is how the rule sticks.

Filling out a thin table

can_vote started with one lonely typical case, (25, True). Four more rows turn it into a suite that actually pins the behavior down, adding the boundary trio from lesson 3-2 plus the zero case.

def can_vote(age):
    return age >= 18

cases = [
    (25, True),
    (17, False),
    (18, True),
    (19, True),
    (0, False),
]

for age, expected in cases:
    got = can_vote(age)
    assert got == expected, f"can_vote({age}) returned {got}, expected {expected}"
    print(f"PASS can_vote({age}) == {expected}")
print(f"{len(cases)} cases passed")

Output

PASS can_vote(25) == True
PASS can_vote(17) == False
PASS can_vote(18) == True
PASS can_vote(19) == True
PASS can_vote(0) == False
5 cases passed

The trio is the interesting part. Probing 17, 18, and 19 around the >= 18 cutoff catches both classic off-by-one errors, since a mistaken > 18 fails on 18 and a mistaken >= 17 fails on 17.

Each new probe is one tuple and nothing else. No new function, no new arrange and act and assert phases, and no second copy of the loop, which is exactly the duplication the table pattern removes.

The final line prints len(cases) rather than a hard-coded 5, so the count stays honest as the table grows. Hard-coding it is a small lie that goes unnoticed for a long time, and a suite that claims 12 cases while running 5 is worse than one that claims nothing.

The rows are checked in table order, which is why the output follows the order the tuples are written. One consequence is that the first failing row stops the run, so a table-driven test trades per-case reporting for brevity unless the loop wraps each case in its own try.