Course outline · 0% complete

0/29 lessons0%

Course overview →

A TDD Walkthrough

lesson 4-2 · ~12 min · 11/29

Building fizzbuzz test-first

The spec: fizzbuzz(n) returns "Fizz" for multiples of 3, "Buzz" for multiples of 5, "FizzBuzz" for multiples of both, and the number as a string otherwise.

Beat 1, red. We write two tests before any real code. The function is a stub returning None, so both tests must fail. Run the next block and enjoy the red: this failure is planned and it proves both tests can detect a broken implementation.

Beat 1, the planned failure

Two tests written before any real code. The function is a stub returning None, so both must fail, and this failing report is the intended result of the step.

def fizzbuzz(n):
    return None

def test_multiples_of_3_say_fizz():
    assert fizzbuzz(3) == "Fizz"

def test_other_numbers_echo_as_text():
    assert fizzbuzz(4) == "4"

tests = [test_multiples_of_3_say_fizz, test_other_numbers_echo_as_text]
passed = 0
for test in tests:
    try:
        test()
        print("PASS", test.__name__)
        passed += 1
    except AssertionError:
        print("FAIL", test.__name__)
print(f"{passed}/{len(tests)} passed")

Output

FAIL test_multiples_of_3_say_fizz
FAIL test_other_numbers_echo_as_text
0/2 passed

Both assertions compare against an exact string, which is what makes them fail against None. Compare that with the assert not result from the previous lesson, which would have passed against this same stub and taught you nothing.

The 0/2 passed line is the useful artifact of this step. It confirms that both tests can detect a broken implementation, so when they turn green later, the green means something.

Note that the tests are already checking two distinct behaviors, the Fizz rule and the echo rule. Strict TDD would add them one at a time, and doing two at once is a reasonable compromise when the behaviors are this small and independent.

Beat 2, green. Write just enough code to pass, run, and the report flips. Beat 3, refactor if anything is ugly, keeping the report green.

Then the loop repeats: next red test ("Buzz" for 5), minimal green, refactor. Then again for "FizzBuzz" at 15. The next block shows the state after three full loops, with all four behaviors tested and implemented. Notice the order of the if checks: 15 must be tested first, because a multiple of 15 is also a multiple of 3, and an early "Fizz" return would win. That subtlety was forced out by the red test for 15, not remembered by luck.

After three loops, all green

The state after three red-green-refactor cycles, with four behaviors tested and implemented.

def fizzbuzz(n):
    if n % 15 == 0:
        return "FizzBuzz"
    if n % 3 == 0:
        return "Fizz"
    if n % 5 == 0:
        return "Buzz"
    return str(n)

def test_multiples_of_3_say_fizz():
    assert fizzbuzz(3) == "Fizz"

def test_multiples_of_5_say_buzz():
    assert fizzbuzz(5) == "Buzz"

def test_multiples_of_both_say_fizzbuzz():
    assert fizzbuzz(15) == "FizzBuzz"

def test_other_numbers_echo_as_text():
    assert fizzbuzz(4) == "4"

tests = [test_multiples_of_3_say_fizz, test_multiples_of_5_say_buzz, test_multiples_of_both_say_fizzbuzz, test_other_numbers_echo_as_text]
passed = 0
for test in tests:
    try:
        test()
        print("PASS", test.__name__)
        passed += 1
    except AssertionError:
        print("FAIL", test.__name__)
print(f"{passed}/{len(tests)} passed")

Output

PASS test_multiples_of_3_say_fizz
PASS test_multiples_of_5_say_buzz
PASS test_multiples_of_both_say_fizzbuzz
PASS test_other_numbers_echo_as_text
4/4 passed

The order of the if checks is the substance of this example. The 15 case must be tested first, because a multiple of 15 is also a multiple of 3, so an earlier "Fizz" return would win and 15 would come back as "Fizz".

That ordering was forced out by the red test for 15 rather than remembered by luck, which is the clearest single demonstration of what TDD buys you. Writing the implementation first, most people write the 3 and 5 branches in the obvious order and discover the 15 problem later, or never.

return str(n) for the fallthrough case is why the echo test asserts the string "4" and not the integer 4. Returning a mix of strings and numbers from one function is a real design smell, and the test locks in the consistent choice.

Playing the green beat

The red tests for is_palindrome are already written and failing. The task is the green step: make all three pass.

def is_palindrome(text):
    lowered = text.lower()
    return lowered == lowered[::-1]

def test_palindrome_word():
    assert is_palindrome("level") == True

def test_non_palindrome_word():
    assert is_palindrome("python") == False

def test_ignores_case():
    assert is_palindrome("Level") == True

tests = [test_palindrome_word, test_non_palindrome_word, test_ignores_case]
passed = 0
for test in tests:
    try:
        test()
        print("PASS", test.__name__)
        passed += 1
    except AssertionError:
        print("FAIL", test.__name__)
print(f"{passed}/{len(tests)} passed")

Output

PASS test_palindrome_word
PASS test_non_palindrome_word
PASS test_ignores_case
3/3 passed

Slicing with [::-1] reverses a string, which is the trick from Advanced Python, and comparing a string to its reverse is the whole definition of a palindrome.

The lowering has to happen before the comparison, and doing it once into lowered is what makes both sides agree. Writing text.lower() == text[::-1] would compare a lowercased string against an unmodified reverse, and test_ignores_case is the test that catches it.

Note which behavior the third test forced into existence. Without it, return text == text[::-1] passes the first two tests, so the case-insensitivity is in the code only because a test demanded it. That is TDD working exactly as intended.

A real palindrome checker would also need to ignore spaces and punctuation, so that a phrase like "A man, a plan, a canal: Panama" counts. There is no test for that here, so the code correctly does not implement it, which is the discipline of writing no more than the tests require.