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
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
assert sumnums(1, 3) == 6 assert sumnums(1, 1) == 1
But what about the following?
assert sumnums(3, 1) == 0
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
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
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 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.