Параметризация в PyTest
Введение | |
Проверка списка неизвестной длины | |
Трехзначные числа | |
Умножение двух чисел | |
Квадратное уравнение | |
Проверка сайта | |
Похожие статьи |
Введение
Вместо запуска одного и того же теста с разными входными данными удобнее сделать один тест, который будет принимать несколько наборов аргументов.
Создание таких обобщающих тестов обычно и называют
параметризацией
Этот способ потом легче масштабировать. Также при наличии тяжёлых для
вычисления шагов, их повтор плохо скажется на производительности.
Например, гораздо эффективнее обратиться к
базе данных
в начале теста а в конце закрыть соединение, чем открывать и закрывать соединение для каждого набора данных.
Официальная документация
Аналог этой статьи для
Robot Framework
можно изучить
здесь
Решение проблемы с видимостью модулей -
здесь
Чтобы запустились все примеры желательно выполнить что-то похожее на
python -m venv venv
venv\Scripts\activate
python -m pip install --upgrade pip
python -m pip install pytest requests xmlschema
Небольшая демонстрация того как работает декоратор @pytest.mark.parametrize, с помощью которого и проиходит параметризация в PyTest
import pytest @pytest.mark.parametrize("test_input, expected_result", [ ("99 > 99", False), ("100 > 99", True), ("2*2", 5), ("1+2", 3) ]) def test_compare(test_input, expected_result): assert eval(test_input) == expected_result
======================== test session starts ========================== collected 4 items tests/test_app.py::test_compare[99 > 99-False] PASSED [ 25%] tests/test_app.py::test_compare[100 > 99-True] PASSED [ 50%] tests/test_app.py::test_compare[2*2-5] FAILED [ 75%] tests/test_app.py::test_compare[1+2-3] PASSED [100%] ============================== FAILURES ============================== ________________________ test_compare[2*2-5] _________________________ …
В этом примере мы проверили выражения, которые выполнялись с помощью eval прямо в тесте.
В реальности проверять обычно приходится
работу каких-то внешних функций. Разберем несколько примеров в следующих главах.
Проверка списка неизвестной длины
Функция get_digits() из модуля list_of_digits.py
должна возвращать произвольный набор цифр.
Повторы разрешены.
Цифры это 0, 1, 2… 9.
Функция написана с ошибкой, но её нелегко поймать одиночным тестом.
Особое внимание хочу обратить на то, что мы параметризуем проверку списка заранее неизвестной длины.
С помощью PyTest можно легко получить заранее неопределённое количество тестов. А если нужно и случайное. Как в нашем примере.
Структура проекта
list_of_digits/ ├── list_of_digits.py └── tests └── pytest └── test_list_of_digits.py
# list_of_digits.py import random def get_digits() -> list: n = random.randint(1, 10) result = [] for i in range(0, n): result.append(random.randint(4, 10)) return result
Чтобы тест был веселее, функция возвращает списки псевдослучайной длины. Чем список длиннее, тем больше шансов поймать баг.
# test_list_of_digits.py import pytest import random from list_of_digits import get_digits digits = get_digits() @pytest.mark.parametrize("digit", digits) def test_if_digit(digit): assert 0 <= digit < 10
Запустим тесты из директории list_of_digits несколько раз пока не поймаем ошибку.
python -m pytest -v --no-header .\tests\pytest\test_list_of_digits.py
=============== test session starts =============== collected 2 items tests/pytest/test_list_of_digits.py::test_if_digit[8] PASSED [ 50%] tests/pytest/test_list_of_digits.py::test_if_digit[7] PASSED [100%] ================ 2 passed in 0.01s ================
=============== test session starts =============== collected 10 items tests/pytest/test_list_of_digits.py::test_if_digit[9_0] PASSED [ 10%] tests/pytest/test_list_of_digits.py::test_if_digit[4] PASSED [ 20%] tests/pytest/test_list_of_digits.py::test_if_digit[7] PASSED [ 30%] tests/pytest/test_list_of_digits.py::test_if_digit[8_0] PASSED [ 40%] tests/pytest/test_list_of_digits.py::test_if_digit[9_1] PASSED [ 50%] tests/pytest/test_list_of_digits.py::test_if_digit[8_1] PASSED [ 60%] tests/pytest/test_list_of_digits.py::test_if_digit[6_0] PASSED [ 70%] tests/pytest/test_list_of_digits.py::test_if_digit[8_2] PASSED [ 80%] tests/pytest/test_list_of_digits.py::test_if_digit[10] FAILED [ 90%] tests/pytest/test_list_of_digits.py::test_if_digit[6_1] PASSED [100%] ===================== FAILURES =====================
В первом
тест-ране
был создан список из двух элементов. Его проверка не выявила ошибок.
Благодаря @pytest.mark.parametrize каждая проверка была выполнена как отдельный тест. Поэтому мы видим два
результата PASSED
Во втором
тест-ране
список был уже из десяти элементов. Мы проверили каждый элемент из списка.
Девятый элемент оказался равен 10, а это не цифра. Поэтому тест упал.
Из этого теста хорошо видно, что PyTest делает с аргументами, переданными в декоратор
@pytest.mark.parametrize("digit", digits)
Первый аргумент хотя и передаётся как строка - становится одноименной переменной, отвечающей за
итерацию
по второму аргументу.
Я специально сделал пример с простейшей итерацией обычной переменной по списку.
Без PyTest мы могли бы написать следующую проверку:
for digit in digits: assert 0 <= digit <= 9, f"{digit} is not a digit"
Но она бы падала при первой ошибке а с PyTest мы видим все падения.
Трехзначные числа
Рассмотрим немного другой по вводным данным пример. Структура проекта остаётся прежней.
filter_three/ ├── three_digit.py └── tests └── pytest └── test_filter_three.py
Функция filter_three() из модуля three_digit.py возвращает True если число трёхзначное.
def filter_three(x: int) -> bool: return x in range(100, 1000)
В предыдущем примере мы всегда ожидали один и тот же результат: каждый элемент должен был быть цифрой.
Теперь когда мы проверяем функцию filter_three() мы будем передавать в неё как трехзначные так и не трёхзначные числа.
С каждым таким тестом придётся передавать и ожидаемый результат.
Наши тестовые данные можно представить как
список
кортежей
вида:
[(Число, Ожидаемый результат), (Число, Ожидаемый результат), (Число, Ожидаемый результат)]
Ключевое отличие от предыдущего теста - здесь мы имеем фиксированый набор тестов. В данном примере их шесть.
Теперь когда мы будем итерировать по тестовым данным нам, для распаковки кортежей понадобятся две переменные, назовём их test_input и expected_result
import pytest from three_digit import filter_three @pytest.mark.parametrize("test_input, expected_result", [ (8, False), (99, False), (100, True), (101, True), (999, True), (1000, False) ]) def test_compare(test_input, expected_result): res = filter_three(test_input) assert res == expected_result
python -m pytest -v --no-header .\tests\pytest\test_filter_three.py
======================== test session starts ========================== collected 6 items tests/pytest/test_filter_three.py::test_compare[9-False] PASSED [ 16%] tests/pytest/test_filter_three.py::test_compare[99-False] PASSED [ 33%] tests/pytest/test_filter_three.py::test_compare[100-True] PASSED [ 50%] tests/pytest/test_filter_three.py::test_compare[101-True] PASSED [ 66%] tests/pytest/test_filter_three.py::test_compare[999-True] PASSED [ 83%] tests/pytest/test_filter_three.py::test_compare[1000-False] PASSED [100%] ========================= 6 passed in 0.02s ===========================
Каждому тесту можно задать отдельное имя с помощью id
import pytest from three_digit import filter_three @pytest.mark.parametrize("test_input, expected_result", [ pytest.param(8, False, id="Digit"), pytest.param(99, False, id="Two Digit Number"), pytest.param(100, True, id="Lower Border"), pytest.param(101, True, id="Three Digit Number"), pytest.param(999, True, id="Upper Border"), pytest.param(1000, False, id="Four Digit Number") ]) def test_compare(test_input, expected_result): res = filter_three(test_input) assert res == expected_result
======================== test session starts ========================== collected 6 items tests/pytest/test_filter_three.py::test_compare[Digit] PASSED [ 16%] tests/pytest/test_filter_three.py::test_compare[Two Digit Number] PASSED [ 33%] tests/pytest/test_filter_three.py::test_compare[Lower Border] PASSED [ 50%] tests/pytest/test_filter_three.py::test_compare[Three Digit Number] PASSED [ 66%] tests/pytest/test_filter_three.py::test_compare[Upper Border] PASSED [ 83%] tests/pytest/test_filter_three.py::test_compare[Four Digit Number] PASSED [100%] ========================= 6 passed in 0.02s ===========================
Умножение двух чисел
Начнём с параметризации простого умножения, пример с которым мы начали разбирать
здесь
Структура проекта:
parametrize/ ├── prod │ ├── prod.py │ └── tests │ └── pytest │ └── test_prod.py └── venv
# prod.py def prod(a, b): return a * b
Запускать тесты будем из директории prod командой
python -m pytest -v --no-header .\tests\pytest\
Здесь тестовые данные будут ещё сложнее. Мы передаем список кортежей. В каждом кортеже находится два элемента. Первый это кортеж из двух умножаемых чисел. Второй - ожидаемый результат в формате int.
[((Множитель, Множитель), Ожидаемый результат), ((Множитель, Множитель), Ожидаемый результат), ((Множитель, Множитель), Ожидаемый результат)]
# test_prod.py import pytest from prod import prod @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
Чтобы не накручивать сложную распаковку, элементы кортежа с множителями перебираются с помощью *args
====================== test session starts ====================== collected 3 items tests/pytest/test_prod_no_id.py::test_basic_param_prod[args0-0] PASSED [ 33%] tests/pytest/test_prod_no_id.py::test_basic_param_prod[args1-0] PASSED [ 66%] tests/pytest/test_prod_no_id.py::test_basic_param_prod[args2-132] PASSED [100%] ==================== 3 passed in 0.01s =========================
Для более наглядной демонстрации результатов можно дать каждому тесту название, с помощью pytest.param id=
# test_prod.py import pytest from prod import prod @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
====================== test session starts ====================== collected 3 items tests/pytest/test_prod.py::test_basic_param_prod[zero - zero] PASSED [ 33%] tests/pytest/test_prod.py::test_basic_param_prod[positive - negative] PASSED [ 66%] tests/pytest/test_prod.py::test_basic_param_prod[float positive - positive] PASSED [100%] ==================== 3 passed in 0.01s =========================
Квадратное уравнение
parametrize/ ├── quad │ ├── quadratic.py │ └── tests │ └── test_quadratic.py └── venv
Запускать тесты будет из директории quad
Простейший вариант параметризованного теста решения квадратного уравнения будет выглядеть следующим образом:
# Параметризация @pytest.mark.parametrize("args, expected_result",[ ((1, -3, -4), (4, -1)), ((0, 0, 0), (None, None)) ])
Удобнее дать каждому тесту название, с помощью pytest.param id=
Рассмотрим полный код теста:
# test_quadratic.py import pytest from quadratic import quadratic_solve, TYPE_ERROR_TEXT def test_raises_type_error(): with pytest.raises(TypeError) as exc_info: quadratic_solve(1, "", 2) assert str(exc_info.value) == TYPE_ERROR_TEXT def test_result_is_tuple(): res = quadratic_solve(0, 0, 0) assert isinstance(res, tuple) @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(args, expected_result): res = quadratic_solve(*args) assert res == expected_result
python -m pytest --no-header -v tests/test_quadratic.py
============================ test session starts =============================== collected 5 items tests/test_quadratic.py::test_raises_type_error PASSED [ 20%] tests/test_quadratic.py::test_result_is_tuple PASSED [ 40%] tests/test_quadratic.py::test_solution[two roots] PASSED [ 60%] tests/test_quadratic.py::test_solution[single root] PASSED [ 80%] tests/test_quadratic.py::test_solution[no roots] PASSED [100%] ============================== 5 passed in 0.01s ==============================
Как можно увидеть при использовании опции -v все три теста [two roots], [single root], [no roots] успешно пройдены.
Проверка сайта
Практический пример применения параметризации для проверки ответов страниц сайта.
Задача проверить все ли страницы сайта heihei.ru
отвечают HTTP кодом 200.
Структура проекта:
from_site_map/ ├── app │ ├── site_map.py │ └── tests │ └── pytest │ └── test_by_site_map.py └── venv
Мой хостинг Beget.com бесплатно создаёт карту сайта. Я скачиваю её в ту же директорию где лежит site_map.py
from_site_map/ ├── app │ ├── site_map.py │ ├── sitemap.xml │ └── tests │ └── pytest │ └── test_by_site_map.py └── venv
Выглядит карта сайта следующим оригинальным образом:
<?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <url> <loc>https://heihei.ru/Spain/cities/malaga/</loc> <priority>0.8</priority> <changefreq>daily</changefreq> </url> <url> <loc>https://heihei.ru/Finland/destinations/</loc> <priority>0.6</priority> <changefreq>daily</changefreq> </url> </urlset>
Только страниц там гораздо больше.
Для парсинга карты сайта будем использовать бибилиотеку xmlschema а для обращения к сайту - библиотеку
requests
Подготовим тестовое окружение. Создадим виртуальное окружение .
python -m venv venv
Активируем его.
В
Windows
команда выглядит следующим образом:
.\venv\Scripts\activate
В Linux немного по-другому:
source ./venv/bin/activate
python -m pip install --upgrade pip
python -m pip install pytest requests xmlschema
Как вариант можно установить зависимости из requirements.txt
pytest==8.3.5 requests==2.32.3 xmlschema==3.4.3
python -m pip install -r requirements.txt
# site_map.py import xml.etree.ElementTree as ET def get_urls() -> list: tree = ET.parse("sitemap.xml") root = tree.getroot() urls = [] for child in root: for gc in child: tag = gc.tag if "loc" in tag: urls.append(gc.text) return urls
Тест будет очень лаконичным. За это мы и любим PyTest
# test_by_site_map.py import pytest import requests from site_map import get_urls endpoints = get_urls() @pytest.mark.parametrize('endpoint', endpoints) def test_status(endpoint): r = requests.get(endpoint, allow_redirects=False) assert r.status_code == 200
Запуск теста выполним следующей командой
python -m pytest -v --no-header tests\pytest\test_by_site_map.py
tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/Georgia/holidays/easter] PASSED [ 0%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/Finland/destinations/] PASSED [ 0%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/Italy/travel/] PASSED [ 0%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/Germany/cities/dusseldorf/ferris_wheel/] PASSED [ 1%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/Norway/destinations/from_oslo_to_paris.php] PASSED [ 1%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/suomen/suomen_har/002_SM2_5-19.php] PASSED [ 1%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/Finland/streetart/malminkartano.php] PASSED [ 1%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/Finland/holidays/laskiainen.php] PASSED [ 1%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/Finland/travel/SPb-Lappeenranta.php] PASSED [ 2%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/Norway/holidays/may1.php] PASSED [ 3%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/suomen/suomen_har/002_SM2_5-02.php] PASSED [ 3%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/Finland//news/2021_06_12_green_mayor_candidate_corruption.php] PASSED [ 4%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/Finland/cities/lappeenranta/fort.php] PASSED [ 4%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/Finland/fests/porvoo_valofest.php] PASSED [ 4%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/Finland/news/2019_05_23_vantaa_bicycle.php] PASSED [ 4%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/Sweden/holidays/st_valentine.php] PASSED [ 4%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/Sweden/museums/skansen/] PASSED [ 5%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/Spain/cities/malaga/] PASSED [ 5%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/Finland/beauty/] PASSED [ 5%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/Finland//shops/kierratyskeskus.php] PASSED … tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/Armenia/holidays/easter.php] PASSED [ 99%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/Norway/destinations/from_oslo_to_rome.php] PASSED [ 99%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/en/Finland/travel/HEL_gosleep.php] PASSED [ 99%] tests/pytest/test_by_site_map.py::test_status[https://heihei.ru/en/Finland/travel/ecolines_kamppi.php] PASSED [100%] =================================== 553 passed in 121.11s (0:02:01) ====================================
Если Python не находит модуль с кодом, можно попробовать перейти в директорию, родительскую по отношению к tests
- в нашем последнем примере это from_site_map и добавить
её в системный путь.
В
PowerShell
команда будет выглядеть так:
$Env:Path += ";$pwd"
В Bash немного по-другому.
export PATH=$PATH:$(pwd)
Автор статьи: Андрей Олегович
PyTest | |
Тестирование | |
Ошибки | |
Видео |