← На главную

Памятка по написанию тестов при помощи PyTest

Допустим, разрабатывается некоторый проект. К проекту требуется написать интеграционные и системные тесты, а также, возможно, нагрузочные и еще какие-то. Для решения этой задачи Python подходит просто идеально. В чем мы с вами скоро и убедимся, познакомившись с фреймворком PyTest и некоторыми плагинами к нему.

Установка:

sudo pip install pytest pytest-quickcheck pytest-html pytest-cov

Чтобы было что тестировать, создадим незамысловатый модуль fibgen, вся реализация которого приведена ниже:

def fibgen(num): """ 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:

from fibgen import fibgen 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:

class TestMath: 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:

Пример использования PyTest через PyCharm

Запуск всех тестов из консоли:

PYTHONPATH=. py.test

Переменная окружения PYTHONPATH нужна для того, чтобы PyTest нашел пакет fibgen. Если вы пишите интеграционные или системные тесты, они вполне могут опираться на модули, объявленные в текущем каталоге. Так что, эту переменную лучше всегда выставлять, чтобы не наткнуться на ошибку вроде ImportError: No module named fibgen.

Желательно всегда использовать verbose режим, чтобы видеть, какие тесты были запущены, видеть полностью какие assert’ы и почему провалились, и так далее:

PYTHONPATH=. py.test -v

Флаг -l включает отображение значений переменных при падении теста:

PYTHONPATH=. py.test -v -l

Запуск конкретного набора тестов, короткая версия:

PYTHONPATH=. py.test -v -l -k TestMath PYTHONPATH=. py.test -v -l -k TestFibgen

Запуск тестов из конкретного файла:

PYTHONPATH=. py.test -v -l tests/subdir/test_math.py

Из конкретного класса:

PYTHONPATH=. py.test -v -l tests/subdir/test_math.py::TestMath

Запуск конкретного метода:

PYTHONPATH=. py.test -v -l \ tests/subdir/test_math.py::TestMath::test_add

Можно указывать несколько тестов:

PYTHONPATH=. py.test -v -l \ tests/subdir/test_math.py::TestMath::test_add \ tests/test_fibgen.py::TestFibgen::test_empty

Тестирование примеров кода из документации (doctest):

PYTHONPATH=. py.test --doctest-modules -v -l

Определение степени покрытия модуля fibgen тестами с выводом номеров непокрытых строк в терминал и генерацией отчета о покрытии в HTML:

PYTHONPATH=. py.test -v -l --cov=fibgen --cov-report term-missing \ --cov-report html

Кстати, оказывается, что в PyCharm Community Edition нет поддержки code coverage, этот функционал есть только в Professional Edition.

C генерацией junit.xml:

PYTHONPATH=. py.test -v -l --junit-xml=junit.xml

С генерацией HTML:

PYTHONPATH=. py.test -v -l --html=out.html

К сожалению, приведенная команда генерирует один большой HTML-файл на все наборы тестов. Решить эту проблему можно командой:

PYTHONPATH=. find tests -type f -iname '*.py' \ -exec py.test -v -l {} --html=html/{}.html \;

Но, увы, в этом случае мы потеряем коды возврата, что плохо работает с системами непрерывной интеграции вроде Jenkins.

Поэтому я в итоге написал такую обвязку:

#!/usr/bin/env python 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 и что, собственно, тестируете с его помощью?

Дополнение: Пример использования PyTest для тестирования проектов на C/C++ вы найдете в посте, посвященном CMake.