Памятка по написанию тестов при помощи PyTest
30 декабря 2015
Допустим, разрабатывается некоторый проект. К проекту требуется написать интеграционные и системные тесты, а также, возможно, нагрузочные и еще какие-то. Для решения этой задачи Python подходит просто идеально. В чем мы с вами скоро и убедимся, познакомившись с фреймворком PyTest и некоторыми плагинами к нему.
Установка:
Чтобы было что тестировать, создадим незамысловатый модуль fibgen, вся реализация которого приведена ниже:
"""
Generates Fibonacci numbers
:param num: how many numbers to generate
:return: generator of first num Fibonacci numbers
>>> list(fibgen(10))
[1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
"""
if type(num) is not int:
raise TypeError("type of num argument should be int")
if num <= 0:
return
x1 = 0
x2 = 1
i = 1
yield 1
while i < num:
i = i + 1
res = x1 + x2
x1 = x2
x2 = res
yield res
if __name__ == "__main__":
print(list(fibgen(10)))
Файл tests/test_fibgen.py:
import pytest
class TestFibgen:
def test_type_error(self):
with pytest.raises(TypeError):
list(fibgen('ololo'))
def test_negative(self):
assert(list(fibgen(-1)) == [])
def test_empty(self):
assert(list(fibgen(0)) == [])
def test_one(self):
assert(list(fibgen(1)) == [1])
def test_two(self):
assert(list(fibgen(2)) == [1,1])
def test_three(self):
assert(list(fibgen(3)) == [1, 1, 2])
def test_seven(self):
# result = list(fibgen(10))
result = list(fibgen(7))
expected = [1, 1, 2, 3, 5, 8, 13]
assert(result == expected)
@pytest.mark.randomize(num=int, min_num=3, max_num=1000, ncalls=99)
def test_quickcheck(self, num):
result = list(fibgen(num))
assert(result[0] < result[-1])
assert(len(result) == num)
Чтобы было несколько тестовых наборов, создадим еще tests/subdir/test_math.py:
def setup_class(self):
print("\n=== TestMath - setup class ===\n")
def teardown_class(self):
print("\n=== TestMath - teardown class ===\n")
def setup(self):
print("TestMath - setup method")
def teardown(self):
print("TestMath - teardown method")
def test_add(self):
assert(2 + 2 == 4)
def test_mul(self):
assert(3 * 3 == 9)
Теперь попробуем разобраться, что же мы здесь видим.
Фреймворк PyTest находит тесты, глядя на имена файлов, классов и методов. Файлы с тестами должны содержать в имени строку «test_», имена классов — начинаться с «Test», а методов — со строки «test_». Все проверки почти всегда выполняются при помощи assert, то есть, никаких навороченных DSL, как в ScalaTest, не предусмотрено. Если требуется проверить, что код бросает некое исключение, можно использовать конструкцию with pytest.raises
, как в тесте test_type_error.
Вдумчивые читатели могли заподозрить property-based тест в коде:
@pytest.mark.randomize(num=int, min_num=3, max_num=1000, ncalls=99)
def test_quickcheck(self, num):
result = list(fibgen(num))
assert(result[0] < result[-1])
assert(len(result) == num)
# ...
Все правильно, это он и есть. Аргумент num будет генерироваться случайным образом из диапазона 3..1000, всего же тест будет запущен 99 раз. Следует однако иметь в виду, что property-based тесты в PyTest намного тупее, нежели в QuickCheck или ScalaCheck. Так, к примеру, никакой гарантированной проверки граничных случаев нет, тест просто выполняется с какими-то случайными значениями. Кроме того, shrinking не поддерживается.
Следующий отрывок кода говорит сам за себя (так называемые «фикстуры»):
def setup_class(self):
print("\n=== TestMath - setup class ===\n")
def teardown_class(self):
print("\n=== TestMath - teardown class ===\n")
def setup(self):
print("TestMath - setup method")
def teardown(self):
print("TestMath - teardown method")
# ...
Метод с именем setup выполняется перед каждыйм тестом, а с именем teardown, вполне закономерно — после. Аналогично метод setup_class выполняется перед началом выполнения всех тестов в классе. После того, как все тесты в классе были выполнены, вызывается teardown_class.
Самый простой способ запустить все тесты — воспользоваться PyCharm:
Запуск всех тестов из консоли:
Переменная окружения PYTHONPATH нужна для того, чтобы PyTest нашел пакет fibgen. Если вы пишите интеграционные или системные тесты, они вполне могут опираться на модули, объявленные в текущем каталоге. Так что, эту переменную лучше всегда выставлять, чтобы не наткнуться на ошибку вроде ImportError: No module named fibgen
.
Желательно всегда использовать verbose режим, чтобы видеть, какие тесты были запущены, видеть полностью какие assert’ы и почему провалились, и так далее:
Флаг -l
включает отображение значений переменных при падении теста:
Запуск конкретного набора тестов, короткая версия:
PYTHONPATH=. py.test -v -l -k TestFibgen
Запуск тестов из конкретного файла:
Из конкретного класса:
Запуск конкретного метода:
tests/subdir/test_math.py::TestMath::test_add
Можно указывать несколько тестов:
tests/subdir/test_math.py::TestMath::test_add \
tests/test_fibgen.py::TestFibgen::test_empty
Тестирование примеров кода из документации (doctest):
Определение степени покрытия модуля fibgen тестами с выводом номеров непокрытых строк в терминал и генерацией отчета о покрытии в HTML:
--cov-report html
Кстати, оказывается, что в PyCharm Community Edition нет поддержки code coverage, этот функционал есть только в Professional Edition.
C генерацией junit.xml:
С генерацией HTML:
К сожалению, приведенная команда генерирует один большой HTML-файл на все наборы тестов. Решить эту проблему можно командой:
-exec py.test -v -l {} --html=html/{}.html \;
Но, увы, в этом случае мы потеряем коды возврата, что плохо работает с системами непрерывной интеграции вроде Jenkins.
Поэтому я в итоге написал такую обвязку:
import os
import re
import subprocess
html_prefix = "./html/"
idx_html = "<table>\n<tr><td>test</td><td>status</td></tr>\n"
for root, dirs, files in os.walk("tests"):
for fname in files:
if re.search("\\.py$", fname) is not None:
sname = os.path.join(root, fname)
html = html_prefix + sname + ".html"
cmd = "PYTHONPATH=. py.test --doctest-modules -v -l " + \
sname + " --html=" + html
code = subprocess.call(cmd, shell = True)
status = "<p style='color:green;'>Success</p>"
if code != 0:
status = "<p style='color:red;'>FAILURE</p>"
idx_html = idx_html + "<tr><td><a href='" + sname + \
".html'>" + sname + "</a></td><td>" + status + \
"</td></tr>\n"
idx_html = idx_html + "</table>\n"
with open(html_prefix + "index.html", "w") as f:
f.write(idx_html)
Напоследок, как обычно, немного ссылок по теме:
- Официальный сайт PyTest;
- Модули на PyPI: pytest-quickcheck, pytest-html, pytest-cov;
- Еще больше примеров использования фикстур (fixtures) в PyTest;
- Тут чуть больше подробностей про конструкцию pytest.raises;
- Allure — говорят, умеет делать красивые репорты из junit.xml;
- Полная версия исходников к этому посту на GitHub;
А используете ли вы PyTest и что, собственно, тестируете с его помощью?
Дополнение: Пример использования PyTest для тестирования проектов на C/C++ вы найдете в посте, посвященном CMake.
Метки: Python, Тестирование.
Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.