PyTest
Введение
Pytest поддерживает тесты, созданные с
unittest
.
Главное преимущество Pytest заключается в особенностях написания TestCase.
TestCase в pytest — это серия функций в файле Python,
которые начинаются с имени test_.
У Pytest есть и другие отличительные особенности:
- поддержка встроенного оператора assert (не нужно использовать специальные методы self.assert);
- поддержка фильтрации;
- возможность перезапуска, начиная с последнего неудачного теста;
- экосистема из сотен плагинов, расширяющих функциональность.
Написание теста TestSum в pytest выглядит так:
def test_sum(): assert sum([1, 2, 3]) == 6, "Should be 6" def test_sum_tuple(): assert sum((1, 2, 2)) == 6, "Should be 6"
Здесь удалены базовый класс TestCase и любое использование классов в принципе, а также точка входа с командной строки. Как обычно, дополнительная информация представлена на сайте Pytest.
Пример без IDE
Простейший пример в консоле
Linux
Нужно подготовить окружение и создать два файла - с кодом и тестом для этого кода.
Файл с кодом можно назвать как угодно - желательно с маленькой буквы и именем несовпадающим с зарезервированными.
Файл с тестом обычно называют также но с префиксом test_ или реже постфиксом _test
mkdir /home/$(whoami)/python/pytest/qa
cd /home/$(whoami)/python/pytest/qa
python -m venv venv
source venv/bin/activate
python -m pip install --upgrade pip
python -m pip install pytest
touch psum.py test_psum.py
В файле psum.py напишите простую функцию, например сложение
def add(x, y): return x + y
А в файле test_psum.py будет тест для этой функции
from psum import add def test_psum(): assert add(2, 3) == 5, "Should be 5"
Чтобы запустить тест нужно выполнить
pytest test_psum.py
================================================= test session starts ================================================== platform linux -- Python 3.9.5, pytest-7.0.1, pluggy-1.0.0 rootdir: /home/andrei/python/pytest/qa collected 1 item test_psum.py . [100%] ================================================== 1 passed in 0.01s ===================================================
Если функция вернёт результат отличный от ожидаемого, например, в случае ошибки в самом тесте
assert add(2, 3) == 999, "Should be 999"
pytest test_psum.py
================================================= test session starts ================================================== platform linux -- Python 3.9.5, pytest-7.0.1, pluggy-1.0.0 rootdir: /home/andrei/python/pytest/qa collected 1 item test_psum.py F [100%] ======================================================= FAILURES ======================================================= ______________________________________________________ test_psum _______________________________________________________ def test_psum(): > assert add(2, 3) == 999, "Should be 999" E AssertionError: Should be 999 E assert 5 == 999 E + where 5 = add(2, 3) test_psum.py:4: AssertionError =============================================== short test summary info ================================================ FAILED test_psum.py::test_psum - AssertionError: Should be 999 ================================================== 1 failed in 0.02s ===================================================
Структура проекта с тестами на PyTest
Как только проект перерастает микроскопический размер держать тесты в одной директории с кодом становится неудобно.
Предположим, что код проекта лежит в директории app.
Типичным решением будет создание файла с тестом
test_psum.py
в поддиректорию tests
app ├── psum.py ├── tests │ └── test_psum.py └── venv ├── bin ├── include ├── lib ├── lib64 -> lib └── pyvenv.cfg
Если теперь запустить тест из директории app то pytest поймёт как импортировать psum
python -m pytest tests/test_psum.py
Если запустить этот же тест прямо из директории tests - появится ошибка ImportError while importing test module
Тестирование проверки аргументов на тип
Демонстрацю применения PyTest часто начинают с функций сложения или умножения.
Убедимся, что находимся в директрии app и создадим ещё два файла
prod.py
,
tests/test_prod.py
touch prod.py tests/test_prod.py
Теперь структура проекта имеет вид:
app ├── prod.py ├── psum.py ├── __pycache__ │ ├── psum.cpython-39.pyc │ └── test_psum.cpython-39-pytest-7.0.1.pyc ├── tests │ ├── __pycache__ │ ├── test_prod.py │ └── test_psum.py └── venv ├── bin ├── include ├── lib ├── lib64 -> lib └── pyvenv.cfg
Добавим в эти файлы следующий код:
# Code def prod(a, b): return a * b
# Test from prod import prod def test_prod(): res = prod(3, 4) assert res == 12
python -m pytest tests/test_prod.py
Эта функция подразумевает использование чисел. Но валидации аргументов нет.
С помощью
добавим валидацию и напишем тест.
# prod.py def prod(a, b): if not all( map(lambda p: isinstance(p, (int, float)), (a, b)) ): raise TypeError("Not valid argument data type") print("prod.py: Valid arguments") return a * b
# test_prod.py import pytest from prod import prod def test_prod(): res = prod(3, 4) assert res == 12 def test_arguments(): try: # Заведомо неправильный тип данных должен быть пойман # "" это строка а не число res = prod("", 4) # Если испльзовать валидные аргументы исключение не поднимется # и тест упадёт # res = prod(1, 2) except TypeError as err: # if TypeError is caught pass else: print("test_prod.py: Invalid argument is not caught") pytest.fail() # assert False
Вместо pytest.fail() можно использовать assert False, тогде и импортировать pytest необязательно
python -m pytest -v tests/test_prod.py
================================ test session starts ================================ platform linux -- Python 3.9.5, pytest-7.0.1, pluggy-1.0.0 -- /home/andrei/python/pytest/otus/venv/bin/python cachedir: .pytest_cache rootdir: /home/andrei/python/pytest/app collected 2 items tests/test_prod.py::test_prod PASSED [ 50%] tests/test_prod.py::test_arguments PASSED [100%] ================================= 2 passed in 0.01s =================================
Если тест сломается PyTest выдаст следующий результат
================================ test session starts ================================ platform linux -- Python 3.9.5, pytest-7.0.1, pluggy-1.0.0 -- /home/andrei/python/pytest/otus/venv/bin/python cachedir: .pytest_cache rootdir: /home/andrei/python/pytest/app collected 2 items tests/test_prod.py::test_prod PASSED [ 50%] tests/test_prod.py::test_arguments FAILED [100%] ===================================== FAILURES ====================================== __________________________________ test_arguments ___________________________________ def test_arguments(): try: # "" is a string and should not be accepted res = prod(3, 4) except TypeError as err: # if TypeError is caught pass else: print("test_prod.py: Invalid argument is not caught") > pytest.fail() E Failed tests/test_prod.py:19: Failed ------------------------------- Captured stdout call -------------------------------- prod.py: Valid arguments test_prod.py: Invalid argument is not caught ============================== short test summary info ============================== FAILED tests/test_prod.py::test_arguments - Failed ============================ 1 failed, 1 passed in 0.02s ============================
unittest из PyTest
Если на проекте уже есть тесты, созданные на
unittest
можно их не переписывать - PyTest поймёт синтаксис unittest
Рассмотрим
проверку решения квадратного уравнения на unittest
Если запустить эти же тесты с помощью PyTest
python -m pytest -v tests/test_quadratic.py
================================================== test session starts =================================================== platform linux -- Python 3.9.5, pytest-7.0.1, pluggy-1.0.0 -- /home/andrei/python/unittest/app/venv/bin/python cachedir: .pytest_cache rootdir: /home/andrei/python/unittest/app collected 5 items tests/test_quadratic.py::TestQuadratic::test_raises_type_error PASSED [ 20%] tests/test_quadratic.py::TestQuadratic::test_result_is_tuple PASSED [ 40%] tests/test_quadratic.py::TestQuadratic::test_single_root PASSED [ 60%] tests/test_quadratic.py::TestQuadratic::test_two_roots PASSED [ 80%] tests/test_quadratic.py::TestQuadratic::test_zero_a_and_b PASSED [100%] =================================================== 5 passed in 0.01s ====================================================
--last-failed: перезапуск только упавших тестов
Если какой-то тест упал по причине ошибки в самом тесте, нужно исправить его и перезапустить.
Чтобы не перезапускать все тесты можно воспользоваться опцией --last-failed
Предположим в test_prod.py допущена ошибка
def test_prod(): res = prod(3, 4) assert res == 120
python -m pytest test_prod.py
========================================================= test session starts ========================================================== platform linux -- Python 3.9.5, pytest-7.1.1, pluggy-1.0.0 rootdir: /home/andrei/github/pytest1/app collected 2 items tests/test_prod.py F. [100%] =============================================================== FAILURES =============================================================== ______________________________________________________________ test_prod _______________________________________________________________ def test_prod(): res = prod(3, 4) > assert res == 120 E assert 12 == 120 tests/test_prod.py:8: AssertionError --------------------------------------------------------- Captured stdout call --------------------------------------------------------- prod.py: Valid arguments ======================================================= short test summary info ======================================================== FAILED tests/test_prod.py::test_prod - assert 12 == 120 ===================================================== 1 failed, 1 passed in 0.02s ====================================================== (venv) andrei@LL-andrei2 {12:04}~/github/pytest1/app:master ✗
После исправления ошибки можно перезапустить только упавший тест
python -m pytest --last-failed tests/test_prod.py
========================================================= test session starts ========================================================== platform linux -- Python 3.9.5, pytest-7.1.1, pluggy-1.0.0 rootdir: /home/andrei/github/pytest1/app collected 2 items / 1 deselected / 1 selected run-last-failure: rerun previous 1 failure tests/test_prod.py . [100%] =================================================== 1 passed, 1 deselected in 0.01s ==================================================== (venv) andrei@LL-andrei2 {12:06}~/github/pytest1/app:master ✗
В PyCharm есть специальная кнопка Rerun Failed Tests - в виде зелёного треугольника и красного круга с восклицательным знаком внутри.
--capture=no: не скрывать вывод
Если в ваших тестах есть какие-то вызовы print() при обычном запуске pytest их не будет видно
def test_prod(): print("testing prod") res = prod(3, 4) assert res == 120
Чтобы увидеть вывод print() нужно использовать опцию
--capture=no
python -m pytest -v --capture=no test_prod.py
========================================= test session starts ========================================== platform linux -- Python 3.9.5, pytest-7.1.1, pluggy-1.0.0 -- /home/andrei/github/pytest1/venv/bin/python cachedir: .pytest_cache rootdir: /home/andrei/github/pytest1/app collected 2 items tests/test_prod.py::test_prod testing prod prod.py: Valid arguments PASSED tests/test_prod.py::test_arguments PASSED ========================================== 2 passed in 0.01s ===========================================
Тестирование решения квадратного уравнения
Предположим мы решаем квадратное уравнение следующим скриптом.
from math import sqrt TYPE_ERROR_TEXT = "Not valid argument type" def quadratic_solve(a ,b, c): if not all( map( lambda p: isinstance(p, (int, float)), (a, b, c) ) ): raise TypeError(TYPE_ERROR_TEXT) print("Types are OK") if a == 0: if b == 0: # a и b 0: решения нет return None, None return -c / b, None d = b ** 2 - 4 * a * c if d < 0: return None, None d_root = sqrt(d) divider = 2 * a x1 = (-b + d_root) / divider x2 = (-b - d_root) / divider if d == 0: x2 = None elif x2 > x1: x1, x2 = x2, x1 return x1, x2 if __name__ == "__main__": print(quadratic_solve(1, -1, -2)) print(quadratic_solve("", 2, 3))
Можно решать и по-другому, главное что нужно для теста - проверка аргументов и возвращение корней в виде кортежа, где отсутствие корня передаётся как None.
Напишем тест, который проверяет что аргументы это числа, а вернуля кортеж
# test_quadratic.py import pytest from quadratic import quadratic_solve, TYPE_ERROR_TEXT class TestQuadratic: def test_raises_type_error(self): with pytest.raises(TypeError) as exc_info: quadratic_solve(1, "", 2) assert str(exc_info.value) == TYPE_ERROR_TEXT def test_result_is_tuple(self): res = quadratic_solve(0, 0, 0) assert isinstance(res, tuple)
Теперь нужно проверить как-минимум три варианта: когда есть оба корня, когда корень один, когда нет корней.
Можно написать три отдельных теста как это было сделано на
unittest
но удобнее применить встроенную в PyTest параметризацию тестов.
-k: запуск определённого теста
Если в файле с тестами больше одного теста, может возникнуть необходимость запустить только часть
Чтобы запустить тест по его названию нужно воспользоваться опцией -k
python -m pytest -v -k "test_basic_param_prod" tests/test_prod.py
================================================= test session starts ================================================== platform linux -- Python 3.9.5, pytest-7.1.1, pluggy-1.0.0 -- /home/andrei/github/pytest1/venv/bin/python cachedir: .pytest_cache rootdir: /home/andrei/github/pytest1/app collected 18 items / 15 deselected / 3 selected tests/test_prod.py::test_basic_param_prod[args0-0] PASSED [ 33%] tests/test_prod.py::test_basic_param_prod[args1-0] PASSED [ 66%] tests/test_prod.py::test_basic_param_prod[args2-132] PASSED [100%] =========================================== 3 passed, 15 deselected in 0.01s ===========================================
Параметризация
Вместо запуска одного и того же теста с разными входными данными удобнее сделать один тест, который будет принимать несколько наборов аргументов.
Этот способ потом легче масштабировать. Также при наличии тяжёлых для
вычисления шагов, их повтор плохо скажется на производительности.
Например, гораздо эффективнее обратиться к
базе данных
в начале теста а в конце закрыть соединение, чем открывать и закрывать соединение для каждого набора данных.
Простейший вариант будет выглядеть следующим образом:
@pytest.mark.parametrize( "args, expected_result", [ ((0, 0), 0), ((3, 0), 0), ((-11, -12), 132), ]) def test_basic_param_prod(args, expected_result): res = prod(*args) assert res == expected_result
Для более наглядной демонстрации результатов можно дать каждому тесту название, с помощью pytest.param id=
@pytest.mark.parametrize( "args, expected_result", [ pytest.param((0, 0), 0, id="zero - zero"), pytest.param((7, -8), -56, id="positive - negative"), pytest.param((13.0, 14), 182, id="float positive - positive"), ]) def test_param_prod(args, expected_result): res = prod(*args) assert res == expected_result
Простейший вариант параметризованного теста решения квадратного уравнения будет выглядеть следующим образом:
# Параметризация @pytest.mark.parametrize("args, expected_result",[ ((1, -3, -4), (4, -1)), ((0, 0, 0), (None, None)) ])
Удобнее дать каждому тесту название, с помощью pytest.param id=
# Параметризация с именованными наборами данных @pytest.mark.parametrize("args, expected_result",[ pytest.param((1, -3, -4), (4, -1), id="two roots", ), pytest.param((1, -2, 1), (1.0, None), id="single root", ), pytest.param((0, 0, 0), (None, None), id="no roots",) ]) def test_solution(self, args, expected_result): res = quadratic_solve(*args) assert res == expected_result
python -m pytest -v tests/test_quadratic.py
========================================================= test session starts ========================================================== platform linux -- Python 3.9.5, pytest-7.0.1, pluggy-1.0.0 -- /home/andrei/.pyenv/versions/3.9.5/bin/python cachedir: .pytest_cache rootdir: /home/andrei/github/pytest1/app collected 5 items tests/test_quadratic.py::TestQuadratic::test_raises_type_error PASSED [ 20%] tests/test_quadratic.py::TestQuadratic::test_result_is_tuple PASSED [ 40%] tests/test_quadratic.py::TestQuadratic::test_solution[two roots] PASSED [ 60%] tests/test_quadratic.py::TestQuadratic::test_solution[single root] PASSED [ 80%] tests/test_quadratic.py::TestQuadratic::test_solution[no roots] PASSED [100%] ========================================================== 5 passed in 0.01s =========================================================== andrei@LL-andrei2 {12:42}~/github/pytest1/app:master ✗ ➭
Как можно увидеть при использовании опции -v все три теста [two roots], [single root], [no roots] успешно пройдены.
Добавить PyTest в Pycharm
Чтобы добавить PyTest в PyCharm воспользуйтесь следующей инструкцией
Settings (Ctrl + Alt + S) → Tools → Python Integrated Tools → Default test runner:
Выбрать pytest. Если он ещё не был добавлен появится предупреждение и кнопка Fix.
Нужно нажать кнопку Fix
Внизу главного экрана настроек появится сообщение
Installing package 'pytest'
Дождитесь когда оно сменится сообщением об успешной установке и с экрана исчезнет предупреждение.
При успешной конфигурации PyCharm у каждого теста слева появится зелёный треугольник, нажав на который
можно будет запусть данный тест.
Если тесты находятся в отдельной директории, возможно вам придётся вручную указать директорию с
проектом как источник для тестов.
Делается это следующим образом
Правый клик на директорию → Mark Directory as → Test Sources Root
Тестирование | |
Ошибки | |
Видео |