Introduction to Unit Testing with Python
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:
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...)
Import the code you are testing, along with the exceptions it may raise.
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.
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.