Нужно ли делать полное покрытие кода тестами — довольно-таки частая и неоднозначная тема при обсуждении юнит-тестирования. Хотя большинство разработчиков склоняются к тому, что делать его не надо, что это неэффективно и бесполезно, я придерживаюсь противоположного мнения (по-крайней мере, при разработке на Python). В данной статье я приведу пример, как делать полное покрытие кода, и опишу недостатки и преимущества полного покрытия на основе своего опыта разработки.
Для юнит-тестирования и сбора статистики мы используем nose. Его преимущества по сравнению с другими средствами:
Установка nose проблем вызвать не должна — он ставится через easy_install, есть в большинстве Linux-репозиториев или может просто устанавливаться из исходников. Для Python 3 необходимо сделать клон ветки py3k и проинсталлировать из исходников.
Тестироваться будет расчет факториала:
Код работает только на Python 2.6 и не совместим с Python 3. Код сохранен в файле main.py.
Начнем с простых тестов:
Эти тесты только проверяют функциональность. Покрытие кода — 83%:
Добавим еще один класс для стопроцентного покрытия:
Теперь код полностью покрыт тестами:
Теперь, уже на основе реального кода, можно сделать какие-то выводы:
На примере адаптации под Python 3 я хочу показать, как полное покрытие кода помогает в работе. Итак, сначала мы просто запускаем программу под Python 3 и выдается ошибка синтаксиса:
Исправляем:
Теперь программу можно запускать:
Значит ли это, что программа рабочая? Нет! Она рабочая только до вызова reduce, что нам и показывают тесты:
В данном примере все это можно было обнаружить и ручным тестированием. Однако на больших проектах только юнит-тестирование поможет обнаружить такого рода ошибки. И только полное покрытие кода может гарантировать что практически все несоответствия кода и API были устранены.
Ну и собственно, рабочий код, полностью совместимый между Python 2.6 и Python 3:
Тесты показывают полное покрытие и работоспособность программы под разными версиями Python:
Полные покрытие кода — не панацея, которая может защитить от ошибок в программе. Однако это инструмент, который надо знать и использовать. Есть много преимуществ в полном покрытии, а недостаток по сути только один — затраты времени и ресурсов на написание тестов. Но чем больше вы будете писать тестов, тем проще они будут даваться вам в дальнейшем. В наших проектах мы уже больше года обеспечиваем стопроцентное покрытие кода, и хотя по началу было много проблем, сейчас уже покрыть полностью код совершенно не составляет проблем, т.к. отрабатаны все методики и написаны все нужные пакеты. Здесь нет никакой магии (хотя и придется работать с магией Python-а), и нужно только начать.
P.S. Полное покрытие обладает еще одним преимуществом, которое не совсем однозначно, но несомненно важно для тех, кто считает себя профессионалом — оно заставляет лезть внутрь 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-а и понимать как он работает. Такого рода знание пригодится всем, особенно разработчикам библиотек.