Testing

Original notebook by Jarrod Millman, part of the Python-bootcamp.

Modifications Hans Fangohr, Sept 2013:

Motivation

Computing is error prone

In ordinary computational practice by hand or by desk machines, it is the custom to check every step of the computation and, when an error is found, to localize it by a backward process starting from the first point where the error is noted.

    - Norbert Wiener (1948)

More computing, more problems

The major cause of the software crisis is that the machines have become several orders of magnitude more powerful! To put it quite bluntly: as long as there were no machines, programming was no problem at all; when we had a few weak computers, programming became a mild problem, and now we have gigantic computers, programming has become an equally gigantic problem.

    - Edsger W. Dijkstra (1972)

What testing is and is not...

Testing and debugging

Program correctness

Program testing can be used to show the presence of bugs, but never to show their absence!

    - Edsger W. Dijkstra (1969)

In the imperfect world ...

Program languages play an important role

Programmers are always surrounded by complexity; we cannot avoid it. Our applications are complex because we are ambitious to use our computers in ever more sophisticated ways. Programming is complex because of the large number of conflicting objectives for each of our programming projects. If our basic tool, the language in which we design and code our programs, is also complicated, the language itself becomes part of the problem rather than part of its solution.

--- C.A.R. Hoare - The Emperor's Old Clothes - Turing Award Lecture (1980)

Testing and reproducibility

In the good old days physicists repeated each other's experiments, just to be sure. Today they stick to FORTRAN, so that they can share each other's programs, bugs included.

    - Edsger W. Dijkstra (1975)

Pre- and post-condition tests

Program defensively

Be systematic

Automate it

Interface and implementation

Testing in Python

Landscape

Errors & Exceptions

Syntax Errors

In [2]:
while True print 'Hello world'
  File "<ipython-input-2-f4b9dbd125c8>", line 1
    while True print 'Hello world'
                   ^
SyntaxError: invalid syntax

Exceptions

In [3]:
1/0
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
<ipython-input-3-05c9758a9c21> in <module>()
----> 1 1/0

ZeroDivisionError: integer division or modulo by zero
In [4]:
factorial
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-4-62dd995547c1> in <module>()
----> 1 factorial

NameError: name 'factorial' is not defined
In [5]:
'1'+1
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-5-0b5599e2e487> in <module>()
----> 1 '1'+1

TypeError: cannot concatenate 'str' and 'int' objects

Exception handling

In [6]:
try:
   file=open('test.txt')
except IOError:
   print 'No such file'

Raising exceptions

In [7]:
def newfunction():
    raise NotImplementedError

newfunction()
---------------------------------------------------------------------------
NotImplementedError                       Traceback (most recent call last)
<ipython-input-7-218008b7e533> in <module>()
      2     raise NotImplementedError
      3 
----> 4 newfunction()

<ipython-input-7-218008b7e533> in newfunction()
      1 def newfunction():
----> 2     raise NotImplementedError
      3 
      4 newfunction()

NotImplementedError: 

Debugging

In [10]:
def foo(x):
    return 1/x

def bar(y):
    return foo(1-y)

bar(1)
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
<ipython-input-10-e30cf484dce8> in <module>()
      5     return foo(1-y)
      6 
----> 7 bar(1)

<ipython-input-10-e30cf484dce8> in bar(y)
      3 
      4 def bar(y):
----> 5     return foo(1-y)
      6 
      7 bar(1)

<ipython-input-10-e30cf484dce8> in foo(x)
      1 def foo(x):
----> 2     return 1/x
      3 
      4 def bar(y):
      5     return foo(1-y)

ZeroDivisionError: integer division or modulo by zero
In []:
%debug

KeyboardInterrupt
> <ipython-input-8-e30cf484dce8>(2)foo()
      1 def foo(x):
----> 2     return 1/x
      3 

ipdb> x   # is x really 0??

Fixing bugs

In [11]:
def foo(x):
    if x==0:
        return float('Inf')
    else:
        return 1/x

bar(1)
bar(1.0)
Out[11]:
inf
In [12]:
def foo(x):
    try:
        return 1/x
    except ZeroDivisionError:
        return float('Inf')

bar(1)
bar(1.0)
Out[12]:
inf

Test as you code

Type checking

In [14]:
i=raw_input("Please enter an integer: ")
if not isinstance(i,int):
    print "Casting ", i, " to integer."
    i=int(i)
Please enter an integer: 5
Casting  5  to integer.

Assert invariants

In [15]:
if i%3 == 0:
    print 1
elif i%3 == 1:
    print 2
else:
    assert i%3 == 2
    print 3
3

Example

Let's make a factorial function.

In [16]:
%%file myfactorial.py

def factorial2(n):
    """ Details to come ...
    """

    raise NotImplementedError

def test():
     from math import factorial
     for x in range(10):
         print ".",
         assert factorial2(x) == factorial(x), \
                "My factorial function is incorrect for n = %i" % x
Writing myfactorial.py

Let's test it ...

In [17]:
import myfactorial
myfactorial.test()
---------------------------------------------------------------------------
NotImplementedError                       Traceback (most recent call last)
<ipython-input-17-484f34e2cc16> in <module>()
      1 import myfactorial
----> 2 myfactorial.test()

/Users/fangohr/hgdocs/teaching/python/notebook/myfactorial.py in test()
     10      for x in range(10):
     11          print ".",
---> 12          assert factorial2(x) == factorial(x), \
     13                 "My factorial function is incorrect for n = %i" % x

/Users/fangohr/hgdocs/teaching/python/notebook/myfactorial.py in factorial2(n)
      4     """
      5 
----> 6     raise NotImplementedError
      7 
      8 def test():

NotImplementedError: 
.

Looks like we will have to implement our function, if we want to make any progress...

In [18]:
%%file myfactorial.py

def factorial2(n):
    """ Details to come ...
    """

    if n == 0:
        return 1
    else:
        return n*factorial2(n-1)

def test():
     from math import factorial
     for x in range(10):
         assert factorial2(x) == factorial(x), \
                "My factorial function is incorrect for n = %i" % x
 Overwriting myfactorial.py

Let's test it ...

In [19]:
reload(myfactorial)
myfactorial.test()

Seems to be okay so far. However, calling factorial2 with a negative number, say, will result in infinite loop. Thus:

What about preconditions

What happens if we call factorial2 with a negative integer? Or something that's not an integer?

In [20]:
%%file myfactorial.py
def factorial2(n):
    """ Find n!. Raise an AssertionError if n is negative or non-integral.
    """

    assert n>=0. and type(n) is int, "Unrecognized input"

    if n == 0:
        return 1
    else:
        return n*factorial2(n-1)

def test():
     from math import factorial
     for x in range(10):
         assert factorial2(x) == factorial(x), \
                "My factorial function is incorrect for n = %i" % x
Overwriting myfactorial.py

doctests -- executable examples

In [21]:
reload(myfactorial)
from myfactorial import factorial2
[factorial2(n) for n in range(5)]
Out[21]:
[1, 1, 2, 6, 24]
In [22]:
%%file myfactorial.py
def factorial2(n):
    """ Find n!. Raise an AssertionError if n is negative or non-integral.

    >>> from myfactorial import factorial2
    >>> [factorial2(n) for n in range(5)]
    [1, 1, 2, 6, 24]
    """

    assert n >= 0. and type(n) is int, "Unrecognized input"

    if n == 0:
        return 1
    else:
        return n * factorial2(n - 1)

def test():
     from math import factorial
     for x in range(10):
         assert factorial2(x) == factorial(x), \
                "My factorial function is incorrect for n = %i" % x
Overwriting myfactorial.py

Running doctests

In [23]:
!python -m doctest -v myfactorial.py
Trying:
    from myfactorial import factorial2
Expecting nothing
ok
Trying:
    [factorial2(n) for n in range(5)]
Expecting:
    [1, 1, 2, 6, 24]
ok
2 items had no tests:
    myfactorial
    myfactorial.test
1 items passed all tests:
   2 tests in myfactorial.factorial2
2 tests in 3 items.
2 passed and 0 failed.
Test passed.

Real world testing and continuous integration

unittest and nose

Test fixtures (Unittest)

Test runner (nose, pytest)

Testing scientific computing libraries

Such libraries have often testing routines, for example:

In [24]:
import scipy.integrate
scipy.integrate.test()
Running unit tests for scipy.integrate
NumPy version 1.7.1
..

NumPy is installed in /Users/fangohr/anaconda/python.app/Contents/lib/python2.7/site-packages/numpy
SciPy version 0.12.0
SciPy is installed in /Users/fangohr/anaconda/python.app/Contents/lib/python2.7/site-packages/scipy
Python version 2.7.5 |Anaconda 1.7.0 (x86_64)| (default, Jun 28 2013, 22:20:13) [GCC 4.0.1 (Apple Inc. build 5493)]
nose version 1.3.0


----------------------------------------------------------------------
Ran 55 tests in 0.408s

OK (KNOWNFAIL=1)

Out[24]:
<nose.result.TextTestResult run=55 errors=0 failures=0>

Assertions revisited - numerical mathematics

Mathematically

\(x = (\sqrt(x))^2\).

So what is happening here:

In [25]:
import math
assert 2 == math.sqrt(2)**2
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
<ipython-input-25-7136ce5c9672> in <module>()
      1 import math
----> 2 assert 2 == math.sqrt(2)**2

AssertionError: 
In [26]:
math.sqrt(2)**2
Out[26]:
2.0000000000000004

NumPy Testing

What if we consider x and y almost equal? Can we modify our assertion?

In [32]:
import numpy as np
np.testing.assert_almost_equal(2, math.sqrt(2) ** 2)
In [33]:
x=1.000001
y=1.000002
np.testing.assert_almost_equal(x, y, decimal=5)

Testing with py.test

Going beyond doctest and Unittest, there are two frameworks widely spread for regression testing:

Here, we focus on pytest.

The example we use is the myfactorial.py file created earlier:

In [34]:
%load myfactorial.py
In [35]:
def factorial2(n):
    """ Find n!. Raise an AssertionError if n is negative or non-integral.

    >>> from myfactorial import factorial2
    >>> [factorial2(n) for n in range(5)]
    [1, 1, 2, 6, 24]
    """

    assert n >= 0. and type(n) is int, "Unrecognized input"

    if n == 0:
        return 1
    else:
        return n * factorial2(n - 1)

def test():
     from math import factorial
     for x in range(10):
         assert factorial2(x) == factorial(x), \
                "My factorial function is incorrect for n = %i" % x

Providing test functions

(Addition to original notebook, Hans Fangohr, 21 Sep 2013)

py.test is an executable that will search through a given file and find all functions that start with test, and execute those. Any failed assertions are reported as errors.

For example, py.test can run the test() function that has been defined already in myfactorial:

In [36]:
!py.test myfactorial.py
============================= test session starts ==============================
platform darwin -- Python 2.7.5 -- pytest-2.3.5

collecting 0 items
collecting 1 items
collected 1 items 

myfactorial.py .

=========================== 1 passed in 0.01 seconds ===========================

This output (the '.' after myfactorial.py) indicates success. We can get a more detailed output using the -v switch for extra verbosity:

In [37]:
!py.test -v myfactorial.py
============================= test session starts ==============================
platform darwin -- Python 2.7.5 -- pytest-2.3.5 -- /Users/fangohr/anaconda/bin/python

collecting 0 items
collecting 1 items
collected 1 items 

myfactorial.py:16: test PASSED

=========================== 1 passed in 0.01 seconds ===========================

Sometimes, we like having the tests for myfactorial.py gathered in a separate file, for example in test_myfactorial.py. We create such a file, and within the file we create a number of test functions, each with a name starting with test:

In [38]:
%%file test_myfactorial.py

from myfactorial import factorial2 

def test_basics():
    assert factorial2(0) == 1
    assert factorial2(1) == 1
    assert factorial2(3) == 6
    
def test_against_standard_lib():
    import math
    for i in range(20):
        assert math.factorial(i) == factorial2(i)
        
def test_negative_number_raises_error():
    import pytest

    with pytest.raises(AssertionError):    # this will pass if 
        factorial2(-1)                     # factorial2(-1) raises 
                                           # an AssertionError
      
    with pytest.raises(AssertionError):
        factorial2(-10)
    
Writing test_myfactorial.py

We can now run the tests in this file using

In [39]:
!py.test -v test_myfactorial.py 
============================= test session starts ==============================
platform darwin -- Python 2.7.5 -- pytest-2.3.5 -- /Users/fangohr/anaconda/bin/python

collecting 0 items
collecting 3 items
collected 3 items 

test_myfactorial.py:4: test_basics PASSED
test_myfactorial.py:9: test_against_standard_lib PASSED
test_myfactorial.py:14: test_negative_number_raises_error PASSED

=========================== 3 passed in 0.02 seconds ===========================

The py.test command can also be given a directory, and it will search all files and files in subdirectories for files starting with test, and will attempt to run all the tests in those.

Or we can provide a list of test files to work through:

In [40]:
!py.test -v test_myfactorial.py myfactorial.py
============================= test session starts ==============================
platform darwin -- Python 2.7.5 -- pytest-2.3.5 -- /Users/fangohr/anaconda/bin/python

collecting 0 items
collecting 3 items
collecting 4 items
collected 4 items 

test_myfactorial.py:4: test_basics PASSED
test_myfactorial.py:9: test_against_standard_lib PASSED
test_myfactorial.py:14: test_negative_number_raises_error PASSED
myfactorial.py:16: test PASSED

=========================== 4 passed in 0.02 seconds ===========================

Final thoughts

Learn more

In [41]:
!hg tip
changeset:   283:d3ff4ebdb13d
tag:         tip
user:        Hans Fangohr [MBA] <fangohr@soton.ac.uk>
date:        Sat Sep 21 13:13:28 2013 +0100
summary:     Adding IPython Notebooks to support materials, updated notebooks.