Introduction to Unit Testing with Python

Sadly we have not been able to make jsMath show the equations correctly in this article. Here is a PDF version where you can see them correctly.

Author: Tomás Zulberti

¿What's Unit Testing and Why Use It?

Unit Tests are those where each part (module, class, function) of the program is tested separately. Ideally, you will test every function and all possible cases for each one.

Unit testing has several advantages:

  • You can test the program works correctly. In Python, tests let you identify non-existent variables or the expected types in a function (in other languages that would be handled at compile time).
  • You can assure that after a change, all parts of the program still work correctly; both the modified parts and those who depend on them, This is very important when you are part of a team (with some version control system).
  • Tests document the code. Not directly, but since the tests show the expected behaviour of the code, reading the tests you can see what's the expected output for certain inputs. Of course this doesn't mean you can just not write docs.

So, why are there bugs if we can write tests? Because unit testing also has some disadvantages.

  • They take a while to write. Some classes are easy to test, others aren't.
  • When you make large changes in the code (refactoring) you have to update the tests. The larger the change the more work adjusting the tests.
  • Something very important: just because the tests pass, that doesn't mean the system works perfectly. For example, CPython (te python you are probably using) has lots of tests, and still has bugs. Unit tests only guarantee a certain minimal functionality.

How should the test be?

Tests should follow these rules:

  • Tests must run without human action. That means they should not ever ask you to enter a value. The test itself passes all required data to the function.
  • Tests must verify the result of the run without interaction. Again, to decide if the test passed or not, it should not ask you to decide. That means you need to know beforehand the expected result for the given input.
  • Tests should be independent from each other. The output of one test should not depend on the result of a previous test.

Knowing these rules, what conditions should the test check?

  • It should pass when the input values are valid.
  • It should fail or raise an exception when the input values are invalid.

If at all possible, you should start writing tests when you start coding, which lets you:

  • Identify in detail what the code you are to write must do.
  • Know that as soon as your implementation passes the tests, you are finished.

That way you have two advantages:

  • You can tell when you should stop coding.
  • You don't code what you don't need.

Example

Suppose you have the coefficients of a quadratic function and want to find the roots. That is, we have a function of this type:

\[a * x^2 + b * x + c = 0\]

And we want to find the values \(r_{1}\) and \(r_{2}\) so that:

\[a * r_{1}^2 + b * r_{1} + c = 0\]\[a * r_{2}^2 + b * r_{2} + c = 0\]

Also, given the values \(r_{1}\) and \(r_{2}\) we want to find the values of a, b y c. We know that:

\[(x - r_{1}) * (x - r_{2}) = a x^2 + b x + c = 0\]

All this is math you learned at school. Now, let's see some code to do the same thing:

import math


class NotQuadratic(Exception):
    pass


class NoRealRoots(Exception):
    pass


def find_roots(a, b, c):
    """ Given coefficients a, b y c of a quadratic function, find its roots.
    The quadratic function is:
        ax**2 + b x + c = 0

Will return a tupe with both roots where the first root will be less
or equal than the second.
    """
    if a == 0:
        raise NotQuadratic()

    discriminant = b * b - 4 * a * c
    if discriminant < 0:
        raise NoRealRoots()

    root_discriminant = math.sqrt(discriminant)
    first_root = (-1 * b + root_discriminant) / (2 * a)
    second_root = (-1 * b - root_discriminant) / (2 * a)

    # min y max are python functions
    chico = min(first_root, second_root)
    grande = max(first_root, second_root)
    return (chico, grande)


def find_coefficients(first_root, second_root):
    """Given the roots of a quadratic function, return the coefficients.
    The quadratic function is given by:
        (x - r1) * (x - r2) = 0
    """
    # You can reach this result by applying distribution
    return (1, -1 * (first_root + second_root), first_root * second_root)

Finally, let's see the tests we wrote for that code:

import unittest
from polynomial import find_roots, find_coefficients, \
                      NotQuadratic, NoRealRoots


class Testpolynomial(unittest.TestCase):

    def test_find_roots(self):
        COEFFICIENTS_ROOTS = [
            ((1,0,0), (0, 0)),
            ((-1,1,2), (-1, 2)),
            ((-1,0,4), (-2, 2)),
        ]
        for coef, expected_roots in COEFFICIENTS_ROOTS:
            roots = find_roots(coef[0], coef[1], coef[2])
            self.assertEquals(roots, expected_roots)

    def test_form_polynomial(self):
        RAICES_COEFFICIENTS = [
            ((1, 1), (1, -2, 1)),
            ((0, 0), (1, 0, 0)),
            ((2, -2), (1, 0, -4)),
            ((-4, 3), (1, 1, -12)),
        ]

        for roots, expected_coefficients in RAICES_COEFFICIENTS:
            coefficients = find_coefficients(roots[0], roots[1])
            self.assertEquals(coefficients, expected_coefficients)

    def test_cant_find_roots(self):
        self.assertRaises(NoRealRoots, find_roots, 1, 0, 4)

    def test_not_quadratic(self):
        self.assertRaises(NotQuadratic, find_roots, 0, 2, 3)

    def test_integrity(self):
        roots = [
            (0, 0),
            (2, 1),
            (2.5, 3.5),
            (100, 1000),
        ]
        for r1, r2 in roots:
            a, b, c = find_coefficients(r1[0], r2[1])
            roots = find_roots(a, b, c)
            self.assertEquals(roots, (r1, r2))

    def test_integrity_fails(self):
        coefficients = [
            (2, 3, 0),
            (-2, 0, 4),
            (2, 0, -4),
        ]
        for a, b, c in coefficients:
            roots = find_roots(a, b, c)
            coefficients = find_coefficients(roots[0], roots[1])
            self.assertNotEqual(coefficients, (a, b, c))



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

You can download the code here: codigo_unittest.zip It's important that the methods and classes of our tests have the word test in them (initial uppercase for the classes) so that they can be identified as classes used for testing.

Let's see step by step how the test is written:

  1. You import the unittest module that comes with Python. Always to test you need to create a class, even if you are going to test a function. This class has methods called assertX where the X changes. You use them to check that the result is correct. These are the ones I use most:

    assertEqual(value1, value2)

    Check that both values are the same, and fails the test if they aren't. If they are lists, it checks that all values in the list are equal, the same if they are sets.

    assertTrue(condition):

    Checks that the condition is true, and fails if it isn't.

    assertRaises(exception, function, value1, value2, etc...):

    Checks that the exception is raised when you call function with arguments value1, value2, etc...)

  2. Import the code you are testing, along with the exceptions it may raise.

  3. Create a class extending TestCase, which will contain methods to test. Within these methods we will use assertEquals, etc to check that everything is working correctly. In our case, we defined the following functions:

    test_find_roots

    Given a list of coefficients and its roots, test that the result of the roots obtained from that coefficient matches the expected result. This test checks that find_roots works correctly. To do that, we iterate over a list of two tuples:

    • A tuple with the coefficients to call the function.
    • A tuple with the roots expected for those coefficients. These roots were calculated manually, not using the program.

    test_form_polynomial

    Given a list of roots, check that the coefficients are correct. This test checks that form_polynomial works correctly. In this case it's a list of two tuples:

    • One with the roots
    • Th second with the coefficients expected for those roots.
    test_cant_find_roots

    Check that find_roots raises the right exception when real roots can't be found.

    test_not_quadratic

    Checks what happens when the coefficients don't belong to a quadratic function.

    test_integrity

    Given a set of roots, finds the coefficients, and for those coefficients find the roots again. The result of the roundtrip should be the same we started with.

    test_integrity_fails

    Check the case where integrity fails. In this case we use functions whose a value is not 1, so even if the roots are the same, it's not the same quadratic function.

  4. At the end of the test we put this code:

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

    That way, if we run python filename.py it will enter that if, and run all the tests in this file.

Suppose we are in the folder where these files reside:

pymag@localhost:/home/pymag$ ls
polynomial.py test_polynomial.py test_polynomial_fail.py

So, let's run the passing tests:

pymag@localhost:/home/pymag$ python test_polynomial.py
......
----------------------------------------------------------------------
Ran 6 tests in 0.006s

OK

The output shows that all 6 tests in this class ran, and they all passed. Now we'll see what happens when a test fails. For this, what I did was change one of the tests so that the second root is smaller than the first. I changed test_integrity so that one of the expected roots is (2, 1), when it should really be (1, 2) since the first root should be smaller. This is in test_polynomial_fail.py. So, if we run the tests that fail, we see:

pymag@localhost:/home/pymag$ python test_polynomial_fail.py
..F...
======================================================================
FAIL: test_integrity (test_polynomial.Testpolynomial)
----------------------------------------------------------------------
Traceback (most recent call last):
    File "/media/sdb5/svns/tzulberti/pymag/testing_01/source/test_polynomial.py",
    line 48, in test_integrity
    self.assertEquals(roots, expected_roots)
AssertionError: (1.0, 2.0) != (2, 1)

----------------------------------------------------------------------
Ran 6 tests in 0.006s

FAILED (failures=1)

Just as it says, all 6 tests ran again, and one failed, and you can see the error and the line where it happened.

Help PET: Donate

blog comments powered by Disqus

Last change: Thu Sep 9 23:50:34 2010.  -  This magazine is under a Creative Commons license.