0%

Testing and Refactoring Object-oriented Programs · practice

Test Several Cases

grade_letter is a staircase:

def grade_letter(fraction):
    if fraction >= 0.9:
        return "A"

    if fraction >= 0.8:
        return "B"
    ...

Five branches, and four places where the answer changes. Writing one test per case gives you five nearly identical whose only difference is two values. Writing one test for the whole thing risks saying nothing useful when it breaks.

There is a shape that gets both.

A list of cases, one loop

def test_each_band_gets_its_letter():
    cases = [
        (1.0, "A"),
        (0.9, "A"),
        (0.85, "B"),
        (0.6, "D"),
        (0.0, "F"),
    ]

    for fraction, expected in cases:
        assert grade_letter(fraction) == expected

The arrangement is the . Each row is a case, written as the input and what it should produce, and adding a case means adding a line.

Chapter 3 used exactly this shape for data. It reads the same way here, and for the same reason: when things differ only in their values, make the values the data.

The failure that tells you nothing

Run that test against a broken grade_letter and the report says:

test_each_band_gets_its_letter failed
assert grade_letter(fraction) == expected

Which fraction? You cannot tell. The ran an unknown number of times and stopped somewhere.

Fundamentals I mentioned that an assertion can carry a message. In a loop, it stops being a nicety:

    for fraction, expected in cases:
        assert grade_letter(fraction) == expected, (
            f"{fraction} should be {expected}, got {grade_letter(fraction)}"
        )

Now the failure names the case:

0.85 should be B, got C

The rule generalizes: when one test covers several cases, the message has to say which case. Otherwise you have traded away the thing that made small tests useful.

A looping test over eight cases fails. The assertion has no message. What does the report tell you?

Choose cases at the edges

Five branches do not need fifty cases. They need the values where the answer changes, and one either side.

0.9 is the boundary between B and A. Both 0.9 and 0.89 are worth a row, because >= and > are one keystroke apart and only one of them is right. A case at 0.94 tells you almost nothing that 0.9 did not.

This is the same instinct as testing the correct answer and the wrong one. Cases earn their place by being able to fail on their own.

Also worth pinning: dividing by nothing

percentage has a branch that has nothing to do with grades:

def percentage(score, total):
    if total == 0:
        return 0.0

    return score / total

Without that guard, an empty quiz would crash the report with . The guard is a decision someone made, and a test is how that decision survives the next person who thinks the branch looks unnecessary.

Task

Write test_scoring.py, covering quizapp.scoring.

Import grade_letter and percentage, then write three tests:

  • test_each_band_gets_its_letter: one of (fraction, expected_letter) cases, one , one assertion carrying a message that names the case. Cover every letter, and include a boundary itself and a just below it (for example, 0.9 and 0.89).

  • test_percentage_of_an_empty_quiz_is_zero: percentage(0, 0) returns 0.0 rather than raising.

  • test_percentage_divides_the_score_by_the_total: percentage(2, 5) returns 0.4.

The looping test needs at least six cases, and its assertion message must include the fraction that failed.