Full code coverage
Whether it is necessary to do full code coverage with tests is a rather frequent and controversial topic when discussing unit testing. Although most developers are inclined to believe that it is not necessary to do it, that it is inefficient and useless, I have the opposite opinion (at least when developing in Python). In this article I will give an example of how to do full coverage of the code, and describe the disadvantages and advantages of full coverage based on my development experience.
For unit testing and statistics collection we use nose . Its advantages in comparison with other means:
Installing nose should not cause problems - it is installed via easy_install, it is in most Linux repositories, or it can simply be installed from source. For Python 3, you need to make a clone of the py3k branch and install from source.
The factorial calculation will be tested:
The code works only on Python 2.6 and is not compatible with Python 3. The code is saved in the main.py file .
Let's start with simple tests:
These tests only test functionality. Code coverage - 83%: Add another class for 100% coverage:
Now the code is completely covered by tests:
Now, based on real code, we can draw some conclusions:
Using an adaptation for Python 3, I want to show how full coverage of the code helps in the work. So, first we just run the program under Python 3 and a syntax error is thrown: Correct:
Now the program can be run: Does this mean that the program is working? Not! It is operational only until reduce is called, which the tests show us: In this example, all this could be detected by manual testing. However, on large projects, only unit testing will help detect this kind of error. And only full coverage of the code can guarantee that almost all inconsistencies in the code and API have been resolved. Well, actually, the working code is fully compatible between Python 2.6 and Python 3:
Tests show the full coverage and performance of the program under different versions of Python:
Full code coverage is not a panacea that can protect against program errors. However, this is a tool that you need to know and use. There are many advantages to full coverage, and there is essentially only one drawback - the time and resources required to write tests. But the more you write tests, the easier they will be given to you in the future. For more than a year now, in our projects, we have provided 100% coverage of the code, and although there were a lot of problems in the beginning, now covering the code completely is not a problem at all, because all methods have been worked out and all the necessary packages have been written. There is no magic here (although you will have to work with Python magic), and you just need to start.
PS Full coverage has another advantage, which is not entirely unambiguous, but undoubtedly important for those who consider themselves to be professional - it makes you climb inside Python and understand how it works. This kind of knowledge is useful to everyone, especially library developers.
Nose testing tool
For unit testing and statistics collection we use nose . Its advantages in comparison with other means:
- No need to write additional code for binding unit tests
- Built-in metric tools, in particular for calculating coverage percentage
- Python 3 compatible (py3k brunch on google code )
Installing nose should not cause problems - it is installed via easy_install, it is in most Linux repositories, or it can simply be installed from source. For Python 3, you need to make a clone of the py3k branch and install from source.
Initial code example
The factorial calculation will be tested:
#!/usr/bin/env python
import operator
def factorial(n):
if n < 0:
raise ValueError("Factorial can't be calculated for negative numbers.")
if type(n) is float or type(n) is complex:
raise TypeError("Factorial doesn't use Gamma function.")
if n == 0:
return 1
return reduce(operator.mul, range(1, n + 1))
if __name__ == '__main__':
n = input('Enter the positive number: ')
print '{0}! = {1}'.format(n, factorial(int(n)))
The code works only on Python 2.6 and is not compatible with Python 3. The code is saved in the main.py file .
Unit tests
Let's start with simple tests:
import unittest
from main import factorial
class TestFactorial(unittest.TestCase):
def test_calculation(self):
self.assertEqual(720, factorial(6))
def test_negative(self):
self.assertRaises(ValueError, factorial, -1)
def test_float(self):
self.assertRaises(TypeError, factorial, 1.25)
def test_zero(self):
self.assertEqual(1, factorial(0))
These tests only test functionality. Code coverage - 83%: Add another class for 100% coverage:
$ nosetests --with-coverage --cover-erase
....
Name Stmts Exec Cover Missing
-------------------------------------
main 12 10 83% 16-17
----------------------------------------------------------------------
Ran 4 tests in 0.021s
OK
class TestMain(unittest.TestCase):
class FakeStream:
def __init__(self):
self.msgs = []
def write(self, msg):
self.msgs.append(msg)
def readline(self):
return '5'
def test_use_case(self):
fake_stream = self.FakeStream()
try:
sys.stdin = sys.stdout = fake_stream
execfile('main.py', {'__name__': '__main__'})
self.assertEqual('5! = 120', fake_stream.msgs[1])
finally:
sys.stdin = sys.__stdin__
sys.stdout = sys.__stdout__
Now the code is completely covered by tests:
$ nosetests --with-coverage --cover-erase
.....
Name Stmts Exec Cover Missing
-------------------------------------
main 12 12 100%
----------------------------------------------------------------------
Ran 5 tests in 0.032s
OK
conclusions
Now, based on real code, we can draw some conclusions:
- First and foremost , the full coverage of the code does not provide a complete check of the program’s functionality and does not guarantee its operability. In this example, there were no tests to verify the complex type of the argument, although full coverage was provided.
- You can completely cover the code, at least in Python. Yes, you need to operate with built-in functions and know how certain mechanisms work, but this is real, and it has become even easier in Python 3.
- Python is a dynamically typed programming language, and unit testing helps you do type checking. With full coverage, the likelihood that typing is correctly followed throughout the program is much higher.
- Full coverage helps when changing the API of the libraries used and when changing the programming language itself (see the example for Python 3 below). Because it is guaranteed that every line of code is called, all inconsistencies in the code and API will be detected.
- And as a consequence of the previous paragraph, full coverage helps to test the code. For example, when working on a production system, before software integration, you can first test it. Often, normal debugging is not possible (say, if there are no rights on the remote system, and the administrator is engaged in everything), and unit tests will help to understand where the problem is.
Adaptation for Python 3
Using an adaptation for Python 3, I want to show how full coverage of the code helps in the work. So, first we just run the program under Python 3 and a syntax error is thrown: Correct:
$ python3 main.py
File "main.py", line 17
print '{0}! = {1}'.format(n, factorial(int(n)))
^
SyntaxError: invalid syntax
#!/usr/bin/env python
import operator
def factorial(n):
if n < 0:
raise ValueError("Factorial can't be calculated for negative numbers.")
if type(n) is float or type(n) is complex:
raise TypeError("Factorial doesn't use Gamma function.")
if n == 0:
return 1
return reduce(operator.mul, range(1, n + 1))
if __name__ == '__main__':
n = input('Enter the positive number: ')
print('{0}! = {1}'.format(n, factorial(int(n))))
Now the program can be run: Does this mean that the program is working? Not! It is operational only until reduce is called, which the tests show us: In this example, all this could be detected by manual testing. However, on large projects, only unit testing will help detect this kind of error. And only full coverage of the code can guarantee that almost all inconsistencies in the code and API have been resolved. Well, actually, the working code is fully compatible between Python 2.6 and Python 3:
$ python3 main.py
Enter the positive number: 0
0! = 1
$ nosetests3
E...E
======================================================================
ERROR: test_calculation (tests.TestFactorial)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/nuald/workspace/factorial/tests.py", line 9, in test_calculation
self.assertEqual(720, factorial(6))
File "/home/nuald/workspace/factorial/main.py", line 12, in factorial
return reduce(operator.mul, range(1, n + 1))
NameError: global name 'reduce' is not defined
======================================================================
ERROR: test_use_case (tests.TestMain)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/nuald/workspace/factorial/tests.py", line 38, in test_use_case
execfile('main.py', {'__name__': '__main__'})
NameError: global name 'execfile' is not defined
----------------------------------------------------------------------
Ran 5 tests in 0.010s
FAILED (errors=2)
#!/usr/bin/env python
import operator
from functools import reduce
def factorial(n):
if n < 0:
raise ValueError("Factorial can't be calculated for negative numbers.")
if type(n) is float or type(n) is complex:
raise TypeError("Factorial doesn't use Gamma function.")
if n == 0:
return 1
return reduce(operator.mul, range(1, n + 1))
if __name__ == '__main__':
n = input('Enter the positive number: ')
print('{0}! = {1}'.format(n, factorial(int(n))))
import sys
import unittest
from main import factorial
class TestFactorial(unittest.TestCase):
def test_calculation(self):
self.assertEqual(720, factorial(6))
def test_negative(self):
self.assertRaises(ValueError, factorial, -1)
def test_float(self):
self.assertRaises(TypeError, factorial, 1.25)
def test_zero(self):
self.assertEqual(1, factorial(0))
class TestMain(unittest.TestCase):
class FakeStream:
def __init__(self):
self.msgs = []
def write(self, msg):
self.msgs.append(msg)
def readline(self):
return '5'
def test_use_case(self):
fake_stream = self.FakeStream()
try:
sys.stdin = sys.stdout = fake_stream
obj_code = compile(open('main.py').read(), 'main.py', 'exec')
exec(obj_code, {'__name__': '__main__'})
self.assertEqual('5! = 120', fake_stream.msgs[1])
finally:
sys.stdin = sys.__stdin__
sys.stdout = sys.__stdout__
Tests show the full coverage and performance of the program under different versions of Python:
$ nosetests --with-coverage --cover-erase
.....
Name Stmts Exec Cover Missing
-------------------------------------
main 13 13 100%
----------------------------------------------------------------------
Ran 5 tests in 0.038s
OK
$ nosetests3 --with-coverage --cover-erase
.....
Name Stmts Miss Cover Missing
-------------------------------------
main 13 0 100%
----------------------------------------------------------------------
Ran 5 tests in 0.018s
OK
Conclusion
Full code coverage is not a panacea that can protect against program errors. However, this is a tool that you need to know and use. There are many advantages to full coverage, and there is essentially only one drawback - the time and resources required to write tests. But the more you write tests, the easier they will be given to you in the future. For more than a year now, in our projects, we have provided 100% coverage of the code, and although there were a lot of problems in the beginning, now covering the code completely is not a problem at all, because all methods have been worked out and all the necessary packages have been written. There is no magic here (although you will have to work with Python magic), and you just need to start.
PS Full coverage has another advantage, which is not entirely unambiguous, but undoubtedly important for those who consider themselves to be professional - it makes you climb inside Python and understand how it works. This kind of knowledge is useful to everyone, especially library developers.