Параметризация в 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 прямо в тесте.

Этот пример служит для демонстрации pytest.mark.parametrize, вариант работы с такими строками без использования eval() вы можете изучить здесь

В следующем примере будем передавать числа по отдельности.

Ожидаемый результат - проверка того, что первое число строго больше второго.

import pytest @pytest.mark.parametrize( "entry1, entry2, expected_result", [ (99, 100, False), (100, 99, True), (99, 99, False), (-2, 1, False), ], ) def test_compare(entry1, entry2, expected_result): assert (entry1 > entry2) is expected_result

… collected 4 items two.py::test_compare[99-100-False] PASSED [ 25%] two.py::test_compare[100-99-True] PASSED [ 50%] two.py::test_compare[99-99-False] PASSED [ 75%] two.py::test_compare[-2-1-False] PASSED [100%] ========================== 4 passed in 0.04s ==========================

В реальности проверять обычно приходится работу каких-то внешних функций. Разберем несколько примеров в следующих главах.

Проверка списка неизвестной длины

Функция 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)

Дополнительные материалы

Без eval

Пример создания теста подобного примеру из введения без использования eval()

import operator import pytest OPS = { ">": operator.gt, "<": operator.lt, "==": operator.eq, "!=": operator.ne, "<=": operator.le, ">=": operator.ge, "+": operator.add, "-": operator.sub, "*": operator.mul, "/": operator.truediv, } def evaluate(expr: str): # Try operators in order of decreasing length so "==" is matched before "=" for op in sorted(OPS.keys(), key=len, reverse=True): if op in expr: left, right = expr.split(op) left = int(left.strip()) right = int(right.strip()) return OPS[op](left, right) raise ValueError(f"Unsupported expression: {expr}") @pytest.mark.parametrize( "test_input, expected_result", [ ("99 > 99", False), ("100 > 99", True), ("-2 < 1", True), ("-0 == 0", True), ("7 - 3", 4), ("1 + 2", 3), ("30 / 5", 6), ("2 * 2", 5), ], ) def test_compare(test_input, expected_result): assert evaluate(test_input) == expected_result

… collected 8 items noeval.py::test_compare[99 > 99-False] PASSED [ 12%] noeval.py::test_compare[100 > 99-True] PASSED [ 25%] noeval.py::test_compare[-2 < 1-True] PASSED [ 37%] noeval.py::test_compare[-0 == 0-True] PASSED [ 50%] noeval.py::test_compare[7 - 3-4] PASSED [ 62%] noeval.py::test_compare[1 + 2-3] PASSED [ 75%] noeval.py::test_compare[30 / 5-6] PASSED [ 87%] noeval.py::test_compare[2 * 2-5] FAILED [100%] …

Автор статьи: Андрей Олегович