Test driven development with Python

Following on from last week's post, this post is going to look at using the unittest module for test driven development in Python. To keep things simple, the example in this post will look at starting a basic calculator module.

Note: the example in this post uses the latest stable version of Python (currently 3.6.5).

Setting up a test runner

The unittest module can be used as a simple test runner by calling the main function with code similar to the following:

import unittest

if __name__ == '__main__':
    unittest.main()

Running the code above will produce output similar to the following:

$ python calc.py

---------------------------------------------------------------------- 
Ran 0 tests in 0.000s

OK

Note: although the test runner is working correctly, there are currently no tests to execute.

Adding a test case

Test cases can be added by creating a new class which extends the unittest.TestCase class, and then adding a method, for example:

import unittest

class TestAddition(unittest.TestCase):
    def test_adding_integers(self):
        self.assertEqual(4, add(2, 2))

if __name__ == '__main__':
    unittest.main()

The test_adding_integers method uses the assertEqual method to compare 4 and the output of add(2, 2). Unsurprisingly the test should fail when run:

$ python calc.py
E
====================================================================== 
ERROR: test_adding_integers (__main__.TestAddition)
---------------------------------------------------------------------- 
Traceback (most recent call last):
  File "calc.py", line 5, in test_adding_integers
    self.assertEqual(4, add(2, 2))
NameError: name 'add' is not defined

---------------------------------------------------------------------- 
Ran 1 test in 0.000s

FAILED (errors=1)

We now have our first test which will drive the first bit of development. A very simple implementation to pass the test above might look something like the following:

def add(a, b):
    return a + b

Once implemented the failing test should now pass:

$ python calc.py
.
---------------------------------------------------------------------- 
Ran 1 test in 0.000s

OK

Another test

Now the test suite is passing we can add a new test to describe the next feature, in this case the add function now needs to handle two or three parameters:

class TestAddition(unittest.TestCase):
    def test_adding_integers(self):
        self.assertEqual(4, add(2, 2))
    def test_adding_three_integers(self):
        self.assertEqual(10, add(2, 2, 6)

Initially this test will fail:

$ python calc.py
.E
====================================================================== 
ERROR: test_adding_three_integers (__main__.TestAddition)
---------------------------------------------------------------------- 
Traceback (most recent call last):
  File "calc.py", line 8, in test_adding_three_integers
    self.assertEqual(10, add(2, 2, 6))
TypeError: add() takes 2 positional arguments but 3 were given

---------------------------------------------------------------------- 
Ran 2 tests in 0.000s

FAILED (errors=1)

To get the test to pass, the add function can be updated as follows:

def add(*args):
    return sum(args)

Running the test suite again confirms everything is OK:

$ python calc.py
..
---------------------------------------------------------------------- 
Ran 2 tests in 0.000s

OK

A different test case

You can group tests across multiple TestCase objects, this makes it easier to do things like selectively run tests in the future:

class TestMultiplication(unittest.TestCase):
    def test_multipling_integers(self):
        self.assertEqual(12, multiply(3, 4))

The test case above expects a multiply function which doesn't exist yet. Therefore it should initially fail:

$ python calc.py
..E
====================================================================== 
ERROR: test_multipling_integers (__main__.TestMultiplication)
---------------------------------------------------------------------- 
Traceback (most recent call last):
  File "calc.py", line 12, in test_multipling_integers
    self.assertEqual(12, multiply(3, 4))
NameError: name 'multiply' is not defined

---------------------------------------------------------------------- 
Ran 3 tests in 0.000s

FAILED (errors=1)

When writing tests it's important to think about different parameters. It's obviously not possible to test everything, however the test above can be passed with the following implementation:

def multiply(a, b):
    return 12

Running the test suite confirms the implementation above passes:

$ python calc.py
...
---------------------------------------------------------------------- 
Ran 3 tests in 0.000s

OK

To get around this a new test similar to the following can be added to complement the existing test:

def test_multipling_decimal_numbers(self):
    self.assertEqual(0.2, multiply(0.4, 0.5))

As always the new test will initially fail:

$ python calc.py
..F.
====================================================================== 
FAIL: test_multipling_decimal_numbers (__main__.TestMultiplication)
---------------------------------------------------------------------- 
Traceback (most recent call last):
  File "calc.py", line 15, in test_multipling_decimal_numbers
    self.assertEqual(0.2, multiply(0.4, 0.5))
AssertionError: 0.2 != 12

---------------------------------------------------------------------- 
Ran 4 tests in 0.000s

FAILED (failures=1)

To make both test cases pass the implementation can be updated to something similar to the following:

def multiply(a, b):
    return a * b

As expected all tests now pass:

$ python calc.py
....
---------------------------------------------------------------------- 
Ran 4 tests in 0.000s

OK

Refactoring implementation code

The final test case in this post is going to require the multiply function to cope with three parameters:

def test_multipling_three_numbers(self):
    self.assertEqual(64, multiply(2, 8, 4)

As with the previous test cases, it should initially fail:

$ python calc.py
....E
====================================================================== 
ERROR: test_multipling_three_numbers (__main__.TestMultiplication)
---------------------------------------------------------------------- 
Traceback (most recent call last):
  File "calc.py", line 18, in test_multipling_three_numbers
    self.assertEqual(64, multiply(2, 8, 4))
TypeError: multiply() takes 2 positional arguments but 3 were given

---------------------------------------------------------------------- 
Ran 5 tests in 0.010s

FAILED (errors=1)

An initially implementation might look something like the following:

def multiply(*args):
    product = 1
    for arg in args:
        product *= arg
    return product

Running the test suite confirms this passes:

$ python calc.py
.....
---------------------------------------------------------------------- 
Ran 5 tests in 0.000s

OK

However the implementation is not particularly tidy, and can be simplified using the reduce function:

from functools import reduce

def multiply(*args):
    return reduce((lambda x, y: x * y), args)

Once the implementation is updated the test suite can be re-run to verify everything is still working as expected:

$ python calc.py
.....
---------------------------------------------------------------------- 
Ran 5 tests in 0.000s

OK

Future steps

Software testing is obviously a very large topic, and the calc example in this post is deliberately simplistic. If you want to learn more I would recommend reading the Python unittest docs as a starting point.

There is also Test-Driven Development with Python by Harry J.W. Percival, I've not read the book yet, however it looks like a good resource if you want a more in-depth look at writing tests in Python.