20.5. Writing Unit Tests

Once you have designed a testable function, with a clear docstring specification, writing unit tests is not difficult. In this section, you’ll learn how to do just that.

Let’s start with our sumnums function:

def sumnums(lo, hi):
    """computes the sum of a range of numbers

    Precondition: lo <= hi
    Postcondition: returns the sum of the numbers in the range [lo..hi]
    """

    sum = 0
    for i in range(lo, hi+1):
        sum += i
    return sum

As we’ve seen, to write a unit test, you devise test cases for the function, and then write assert statements that call the function and check that the function produced the expected results. The following assert statements would be appropriate for a unit test for sumnums:

assert sumnums(1, 3) == 6
assert sumnums(1, 1) == 1

But what about the following?

assert sumnums(3, 1) == 0

Note that sumnums produces the value 0 for cases where the lo values exceeds the hi value, as is the case in this assert. So, like the first two asserts above, this assert would pass. However, it is not an appropriate assertion, because the specification says nothing about what the function produces if lo is greater than hi.

The unit test should be written such that it passes even if the function implementation is altered in a way that causes some other value than 0 to be returned if lo exceeds hi. For example, we might want to redesign the function to be more efficient — for example, use Gauss’s formula for summing numbers, as in the following:

def sumnums(lo, hi):
    """computes the sum of a range of numbers

    Precondition: lo <= hi
    Postcondition: returns the sum of the numbers in the range [lo..hi]
    """

    return (hi * (hi + 1) / 2) - (lo * (lo - 1) / 2)

This version will produce correct results if the precondition is satisfied. Like the original function, it produces incorrect results if the precondition is violated — but unlike the original function, the values produced if the precondition is violated are not necessarily 0.

20.5.1. Specification-Based Testing

A key idea to remember when writing a unit test is that your test must always respect the function’s preconditions. The docstring states what the function should do, with the assumption that parameter values meet the preconditions. It does not state what the function should do if the parameter values violate the preconditions.

Writing an assert that violates the functions preconditions is not a good idea, because to determine what the function will produce for that case, you must look into the implementation of the function and analyze its behavior. That is called implementation-based testing, and it leads to brittle tests that are likely to fail if you rework the function implementation. When you write tests are based only on the function specification, without looking at the implementation, you are doing specification-based testing.

Specification-Based Tests

Specification-based tests are tests that are designed based only on the information in the function specification, without considering any of the details in the function implementation.

Specification-based tests are preferred over implementation-based tests, because they are more resilient. They will continue to pass even if you rework the function implementation.

Check your understanding

Write assert statements below to test a function with the following specification. Your asserts should check that the function produces an appropriate value for each of the three postcondition cases.

def grade(score):
    """Determines letter grade given a numeric score

    Precondition: 0 <= `score` <= 100
    Postcondition: Returns 'A' if 90 <= `score` <= 100,
      'B' if 80 <= `score` < 90, 'F' if 0 <= `score` < 80
    """

Note: Line numbers in any assert error messages that appear while you are developing and testing your answer will not be accurate.

The following asserts are just some of several that could have been used.

{{ if is_instructor: }} {{ pass }}
You have attempted of activities on this page