Параметризация в 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
Тестирование
Ошибки
Видео

Поиск по сайту

Подпишитесь на Telegram канал @aofeed чтобы следить за выходом новых статей и обновлением старых

Перейти на канал

@aofeed

Задать вопрос в Телеграм-группе

@aofeedchat

Контакты и сотрудничество:
Рекомендую наш хостинг beget.ru
Пишите на info@urn.su если Вы:
1. Хотите написать статью для нашего сайта или перевести статью на свой родной язык.
2. Хотите разместить на сайте рекламу, подходящую по тематике.
3. Реклама на моём сайте имеет максимальный уровень цензуры. Если Вы увидели рекламный блок недопустимый для просмотра детьми школьного возраста, вызывающий шок или вводящий в заблуждение - пожалуйста свяжитесь с нами по электронной почте
4. Нашли на сайте ошибку, неточности, баг и т.д. ... .......
5. Статьи можно расшарить в соцсетях, нажав на иконку сети: