Hi, I'm Harlin and welcome to my blog. I write about Python, Alfresco and other cheesy comestibles.

Python - Basic Unit Testing and Test Driven Development

In case you're unfamiliar with it, code testing is proving your code works as expected. Unit testing does this too, of course, but in much smaller bites. There's also this other thing called Test Driven Development. There are also many other subjects that have to do with code testing but I just want to cover unit testing and test driven development.

Unit Testing

Unit testing is a testing practice that breaks down your application into the smallest units and ensures each works as expected. The code for your application is pieced together by classes, functions and other routines. You can think any grouping of these components as an API. An API basically creates a contract enforces input and expected output. Unit testing allows you to test each of those components to ensure those "contracts" are being honored.

It's useful in that as the more complex your application gets (bugs fixed, features added and modified, etc.), you will always know whether any changes introduced to it have affected in any way.

Let's use an example of a routine that takes two integers, doubles them, multiplies them together and then returns half of the product back (from a file called app.py):

#!/usr/bin/env python

def doubled(num):
    return num * 2


def multiplied(d1, d2):
    return d1 * d2


def halved(product):
    return float(product) / 2


if __name__ == '__main__':
    num1, num2 = map(int, input('Enter two numbers: ').split())

    d1 = doubled(num1)
    d2 = doubled(num2)

    product = multiplied(d1, d2)
    half = halved(product)

    print(half)

If we were to apply unit testing for this script, we would write tests for each function: doubled, multiplied and halved. There are a number of test utilities you can use but if you're just starting out, I would highly recommend using the unittest module that comes out of the box with Python 2.7 and up. It makes use of the standard unit test idioms like assertEqual, assertTrue, assertFalse, etc.

As an example, we could use the following code to test the functions from app.py (this file is called test.py):


#!/usr/bin/env python

import unittest
from app import doubled, multiplied, halved


class MyTestClass(unittest.TestCase):

    def test_doubled(self):
        added = doubled(5)
        self.assertEqual(added, 10)

    def test_mulitplied(self):
        product = multiplied(5, 5)
        self.assertEqual(product, 25)

    def test_halved(self):
        result = halved(10)
        self.assertEqual(result, 5.0)

        result = halved(5)
        self.assertEqual(result, 2.5)


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

When we run ./test.py and assuming all tests pass, we should see this output:

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

OK

As an example, if we change some of the numbers around in assertEqual statements (I changed 10 to 11 in test_doubled()), we should see a fail statement like this:

F..
======================================================================
FAIL: test_doubled (__main__.MyTestClass)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "./test.py", line 11, in test_doubled
    self.assertEqual(added, 11)
AssertionError: 10 != 11

----------------------------------------------------------------------
Ran 3 tests in 0.001s

FAILED (failures=1)

Each function (test_doubled, test_multiplied, test_halved) has a test in it that runs an assert statement. Like any other stacktrace, it will tell us which line the failure came from and raise an AssertionError. Runtime errors will show as well but those shouldn't be misunderstood as test failures. They just mean our code with the tests likely wasn't sound.

Below is an example of how it's used (in a file called mytest.py):


import unittest

class MyTestClass(unittest.TestCase):

    def test_doubled(self):
        added = doubled(5)
        self.assertEqual(added, 10)

    def test_mulitplied(self):
        product = multiplied(5, 5)
        self.assertEqual(product, 25)

    def test_halved(self):
        result = halved(10)
        self.assertEqual(result, 5.0)

        result = halved(5)
        self.assertEqual(result, 2.5)


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

Test Driven Development

Now, you may have heard of something called Test Driven Development (TDD). TDD and Unit Testing are not opposite each other or strictly comparable. You can do unit testing without TDD but you generally are not going to do TDD without some unit testing.

Test Driven Development is about "when" you're going to test and when you're going to write code. As the name might imply, with TDD you're going to write tests first, let them fail and then write the minimum code that it takes to make the tests pass. So, with this same script that we have, let me give you an example of how you can use the same test and same script to build a la TDD:

Let's imagine we have no app.py at this point and no code in it. So, first, let's write a test:


import unittest

class MyTestClass(unittest.TestCase):

    def test_doubled(self):
        added = doubled(5)
        self.assertEqual(added, 10)


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

And then run it. We should see this error:

E
======================================================================
ERROR: test_doubled (__main__.MyTestClass)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "./test.py", line 10, in test_doubled
    added = doubled(5)
NameError: name 'doubled' is not defined

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

FAILED (errors=1)

Hmm ok. We don't have doubled() defined. We'll need to import that from the app.py file. So, let's add an import statement that pulls in doubled (and to save some space here, this will pull in the other 2 functions):


import unittest
from app import doubled, multiplied, halved
...

If we run the test again, it will still fail:

Traceback (most recent call last):
  File "./test.py", line 4, in <module>
    from app import doubled, multiplied, halved
ImportError: cannot import name 'doubled'

This tells us that in the app.py file, we need to write a doubled() function. So, in app.py let's add this function:

#!/usr/bin/env python

def doubled(num):
    return num * 2

Again for brevity, let's go ahead and add these two functions so we don't get other runtime errors for now while we're testing doubled():


def multiplied():
    pass


def halved():
    pass

Now, let's run our test again and we should see:

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

OK

All right. That one passed. Now, let's go ahead and flesh out our other two tests and write the code in app.py that will allow each of those tests to pass:

In test.py:


def test_mulitplied(self):
        product = multiplied(5, 5)
        self.assertEqual(product, 25)

def test_halved(self):
    result = halved(10)
    self.assertEqual(result, 5.0)

    result = halved(5)
    self.assertEqual(result, 2.5)

In app.py:

def multiplied(d1, d2):
    return d1 * d2


def halved(product):
    return float(product) / 2

You can also go ahead and put in the rest of the process for app.py but the idea of this was to let you see how we let testing drive the coding of the main script itself.

A benefit of this is that it can help you think out the problem you're solving with this script in smaller bits. You also will have a lot of tests that can be run again and again automatically. This is a very simple example but imagine that if you were writing a much larger script or complete app, you would be expected to add features and resolve bugs as you go.

It would not be much of a stretch to anticipate new code that is being introduced to break old code. If you don't test for breaks in older code, you can expect a lot of surprises.

The unit testing methodology used here will help you find breaks in older code should they occur. This way when new features or fixes are rolled out, there will be far less surprises than there would be otherwise.

This methodology also gives a team more confidence to do quicker and continuous integration of code into a production environment.

Any Comments, Always Welcome!