Tests should not contain logic

Tests should contain as little logic as possible. This helps eliminate the test itself as the culprit when a test fails. As a rule of thumb, avoid control flow or conditional logic inside tests.

As a motivating example, consider the following, buggy Fizzbuzz implementation:

def fizzbuzz(n):
    if n % 3 == 0:
        return "fizz"
    if n % 5 == 0:
        return "buzz"
    if n % 15 == 0:
        return "fizzbuzz"
    return str(n)

fizzbuzz(3)
# Correct, returns "fizz"
fizzbuzz(5)
# Correct, returns "buzz"
fizzbuzz(15)
# Incorrect, returns "fizz"

Let’s say we want to test the fizzbuz function with a number of inputs, but we think writing out each assertion by hand is too tedious; instead we write a test like this:

def test_fizzbuzz():
    for n in range(1,20):
        if n % 3 == 0:
             expected = "fizz"
        elif n % 5 == 0:
            expected = "buzz"
        elif n % 15 == 0:
           expected = "fizzbuzz"
        else:
            expected = str(n)
        assert fizzbuzz(n) = expected  

This test repeats the logic error from the implementation, so although the implementation is buggy the test will succeed.

Had we written the “obvious” tests, the test for fizzbuzz(15) would have failed and exposed the logic bug:

def test_fizzbuzz_1():
    assert fizzbuzz(1) == "1"

def test_fizzbuzz_3():
    assert fizzbuzz(3) == "fizz"

def test_fizzbuzz_5():
    assert fizzbuzz(5) == "buzz"

# This assertion fails as fizzbuzz(15) returns "fizz"
def test_fizzbuzz_15():
    assert fizzbuzz(15) == "fizzbuzz"

Parametrized tests

A better way to reduce boilerplate when writing tests is to use parametrized tests (also known as table-driven tests). The idea is to parametrize tests over a series of inputs and expected outputs. Here’s an example of how to do it with the pytest framework:

@pytest.mark.parametrize(
    "in,out", 
    [(1, "1"),
     (3, "fizz"),
     (5, "buzz"),
     (15, "fizzbuzz")]

def test_fizzbuzz(in, out):
    assert fizzbuzz(in) == out