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

30 декабря 2015

Допустим, разрабатывается некоторый проект. К проекту требуется написать интеграционные и системные тесты, а также, возможно, нагрузочные и еще какие-то. Для решения этой задачи 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.

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.