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.