Python
June 2010 22

Полное покрытие кода

Нужно ли делать полное покрытие кода тестами — довольно-таки частая и неоднозначная тема при обсуждении юнит-тестирования. Хотя большинство разработчиков склоняются к тому, что делать его не надо, что это неэффективно и бесполезно, я придерживаюсь противоположного мнения (по-крайней мере, при разработке на Python). В данной статье я приведу пример, как делать полное покрытие кода, и опишу недостатки и преимущества полного покрытия на основе своего опыта разработки.

Инструмент тестирования nose


Для юнит-тестирования и сбора статистики мы используем nose. Его преимущества по сравнению с другими средствами:
  • Не надо писать дополнительный код для обвязки юнит-тестов
  • Встроенные средства для метрик, в частности для вычисления процента покрытия
  • Совместимость с Python 3 (бранч py3k на google code)

Установка nose проблем вызвать не должна — он ставится через easy_install, есть в большинстве Linux-репозиториев или может просто устанавливаться из исходников. Для Python 3 необходимо сделать клон ветки py3k и проинсталлировать из исходников.

Изначальный пример кода


Тестироваться будет расчет факториала:
#!/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)))

Код работает только на Python 2.6 и не совместим с Python 3. Код сохранен в файле main.py.

Юнит-тесты



Начнем с простых тестов:
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))

Эти тесты только проверяют функциональность. Покрытие кода — 83%:
$ 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__

Теперь код полностью покрыт тестами:
$ nosetests --with-coverage --cover-erase
.....
Name Stmts Exec Cover Missing
-------------------------------------
main 12 12 100%
----------------------------------------------------------------------
Ran 5 tests in 0.032s

OK

Выводы


Теперь, уже на основе реального кода, можно сделать какие-то выводы:
  • Первое и самое главное — полное покрытие кода не обеспечивает полную проверку функциональности программы и не гарантирует ее работоспособность. В данном примере не было тестов для проверки комплексного типа аргумента, хотя и было обеспечено полное покрытие.
  • Полностью покрыть код можно, как минимум на Python. Да, необходимо оперировать встроенными функциями и знать как работают те или иные механизмы, но это реально, и стало еще проще в Python 3.
  • Python — динамически типизируемый язык программирования, и юнит-тестирование помогает делать проверку типов. При полном покрытии вероятность того, что типизация корректно соблюдена по всей программе, намного выше.
  • Полное покрытие помогает при изменении API используемых библиотек и при изменении самого языка программирования (см. пример для Python 3 далее). Т.к. гарантируется, что вызовется каждая строчка кода, все несоотвествия кода и API будут обнаружены.
  • И как следствие из предыдущего пункта, полное покрытие помогает тестировать код. Например, при работе на production-системе перед интеграцией софта можно провести сначала его тестирование. Зачастую нормальная отладка невозможна (скажем если нет прав на удаленной системе, и всем занимается администратор), а юнит-тесты помогут понять, где проблема.

Адаптация под Python 3


На примере адаптации под Python 3 я хочу показать, как полное покрытие кода помогает в работе. Итак, сначала мы просто запускаем программу под Python 3 и выдается ошибка синтаксиса:
$ 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))))

Теперь программу можно запускать:
$ python3 main.py
Enter the positive number: 0
0! = 1

Значит ли это, что программа рабочая? Нет! Она рабочая только до вызова reduce, что нам и показывают тесты:
$ 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)

В данном примере все это можно было обнаружить и ручным тестированием. Однако на больших проектах только юнит-тестирование поможет обнаружить такого рода ошибки. И только полное покрытие кода может гарантировать что практически все несоответствия кода и API были устранены.

Ну и собственно, рабочий код, полностью совместимый между Python 2.6 и Python 3:
#!/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__


Тесты показывают полное покрытие и работоспособность программы под разными версиями 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

Заключение


Полные покрытие кода — не панацея, которая может защитить от ошибок в программе. Однако это инструмент, который надо знать и использовать. Есть много преимуществ в полном покрытии, а недостаток по сути только один — затраты времени и ресурсов на написание тестов. Но чем больше вы будете писать тестов, тем проще они будут даваться вам в дальнейшем. В наших проектах мы уже больше года обеспечиваем стопроцентное покрытие кода, и хотя по началу было много проблем, сейчас уже покрыть полностью код совершенно не составляет проблем, т.к. отрабатаны все методики и написаны все нужные пакеты. Здесь нет никакой магии (хотя и придется работать с магией Python-а), и нужно только начать.
P.S. Полное покрытие обладает еще одним преимуществом, которое не совсем однозначно, но несомненно важно для тех, кто считает себя профессионалом — оно заставляет лезть внутрь Python-а и понимать как он работает. Такого рода знание пригодится всем, особенно разработчикам библиотек.
+39
12.5k 69
Comments 35
Top of the day