Параметризація з файлу в py.test
Реалізація параметризації тестів із відповідних їм YAML-файлів через pytest_generate_tests. Приклад розширюється фільтрацією функцій, читабельними ідентифікаторами й маркерами окремих тестових випадків.
Translated by ChatGPT GPT-6.1 Sol
Відомості про першоджерело
Параметризация из файла в py.test
Архівна публікація. Посади, показники й технічні інструкції наведено станом на дату першої публікації.
У сфері автоматизованого тестування можна зустріти різні інструменти. Так, для написання автотестів мовою Python одним із найпопулярніших рішень на цей момент є py.test.
Переглянувши безліч ресурсів, пов’язаних із pytest, і вивчивши документацію з офіційного сайту проєкту, я не зміг знайти прямий опис розв’язання одного з основних завдань — запуску тестів із тестовими даними, що зберігаються в окремому файлі. Інакше кажучи, підвантаження параметрів у тестові функції з файлу(-ів), або параметризації з файлу безпосередньо. Таку процедуру в деталях ніде не описано, і єдина згадка про цю можливість є лише в одному рядку документації pytest.
У цій статті я розповім про своє розв’язання цього завдання.
Завдання
Основне завдання — генерація тестових випадків у вигляді параметрів test_input і expected_result у кожну окрему тестову функцію з файлів, що відповідають назвам функцій.
Додаткові завдання:
-
вибрати читабельне для людини форматування файлів із тест-кейсами;
-
залишити можливість підтримки жорстко заданих у коді тест-кейсів;
-
виводити зрозумілі ідентифікатори для кожного випадку.
Інструментарій
У статті я використовую Python 3 (підійде й 2.7), pyyaml і pytest (версії 5+ для Python 3 або 4.6 для Python 2.7) без використання сторонніх плагінів. Крім того, буде використано стандартну бібліотеку os
Сам файл, із якого ми братимемо тест-кейси, необхідно структурувати, використовуючи зручну для розуміння людиною мову розмітки. У моєму випадку було вибрано YAML (оскільки він розв’язує додаткове завдання з вибору читабельного для людини формату). Насправді те, яка саме вам потрібна мова розмітки файлів із наборами даних, залежить лише від вимог, представлених на проєкті.
Реалізація
Оскільки основним стовпом світобудови в програмуванні є домовленість, нам доведеться запровадити кілька таких і для нашого рішення.
Перехоплення
Почнімо з того, що в цьому рішенні використовується функція перехоплення pytest_generate_tests (wiki), яка запускається на етапі генерації тест-кейсів, та її аргумент metafunc, що дає нам змогу параметризувати функцію. У цьому місці pytest перебирає кожну тестову функцію й виконує для неї подальший код генерації.
Аргументи
Необхідно визначити вичерпний список параметрів для тестових функцій. У моєму випадку — словник test_input і будь-який тип даних (найчастіше рядок або ціле число) в expected_result. Ці параметри потрібні нам для використання в metafunc.parametrize(...).
Параметризація
Ця функція повністю повторює роботу фікстури параметризації @pytest.mark.parametrize, яка першим аргументом приймає рядок із переліком аргументів тестової функції (у нашому випадку "test_input, expected_result") і список даних, перебираючи який вона створюватиме наші тестові випадки (наприклад, [(1, 2), (2, 4), (3, 6)]).
У бою це виглядатиме так:
@pytest.mark.parametrize("test_input, expected_result", [(1, 2), (2, 4), (3, 6)])
def test_multiplication(test_input, expected_result):
assert test_input * 2 == expected_result
А в нашому випадку ми заздалегідь укажемо це:
# Наша реализация...
return metafunc.parametrize("test_input, expected", test_cases) # или `[(1, 2), (2, 4), (3, 6)]`
Фільтрація
Звідси також випливає відокремлення тих тестових функцій, де необхідне підвантаження даних із файлу, від тих, які використовують статичні/динамічні дані. Цю фільтрацію ми застосовуватимемо до початку розбору інформації з файлу.
Самі фільтри можуть бути будь-якими, наприклад:
- Маркер функції з ім’ям
yaml:
# Откидываем варианты вообще без каких-либо маркеров
if not hasattr(metafunc.function, 'pytestmark'):
return
# Берем все маркеры нынешней функции и их имена вносим в список
mark_names = [ mark.name for mark in metafunc.function.pytestmark ]
# Пропускаем эту функцию, если в списке нет выбранного нами маркера
if 'yaml' not in mark_names:
return
Інакше той самий фільтр можна реалізувати так:
# Создаем пустой маркер и ищем такой же в маркерах функции
if Mark(name=’yaml’, args=(), kwargs={}) not in metafunc.function.pytestmark:
return
- Аргумент функції
test_input:
# Пропускаем все функции, у которых нет аргумента test_input
if 'test_input' not in metafunc.fixturenames:
return
Мені найбільше підійшов цей варіант.
Результат
Нам треба дописати лише частину, де ми розбираємо дані з файлу. Це не становитиме труднощів у випадку з yaml (а також json, xml тощо), тому збираємо все докупи.
# conftest.py
import os
import yaml
import pytest
def pytest_generate_tests(metafunc):
# Пропускаем все функции, у которых нет аргумента test_input
if 'test_input' not in metafunc.fixturenames:
return
# Определяем директорию текущего файла
dir_path = os.path.dirname(os.path.abspath(metafunc.module.__file__))
# Определяем путь к файлу с данными
file_path = os.path.join(dir_path, metafunc.function.__name__ + '.yaml')
# Открываем выбранный файл
with open(file_path) as f:
test_cases = yaml.full_load(f)
# Предусматриваем неправильную загрузку и пустой файл
if not test_cases:
raise ValueError("Test cases not loaded")
return metafunc.parametrize("test_input, expected_result", test_cases)
Тестовий скрипт пишемо приблизно такий:
# test_script.py
import pytest
def test_multiplication(test_input, expected_result):
assert test_input * 2 == expected_result
А файл із даними:
# test_multiplication.yaml
- !!python/tuple [1,2]
- !!python/tuple [1,3]
- !!python/tuple [1,5]
- !!python/tuple [2,4]
- !!python/tuple [3,4]
- !!python/tuple [5,4]
Ми отримуємо такий список тест-кейсів:
pytest /test_script.py --collect-only
======================== test session starts ========================
platform linux -- Python 3.7.4, pytest-5.2.1, py-1.8.0, pluggy-0.13.0
rootdir: /pytest_habr
collected 6 items
<Module test_script.py>
<Function test_multiplication[1-2]>
<Function test_multiplication[1-3]>
<Function test_multiplication[1-5]>
<Function test_multiplication[2-4]>
<Function test_multiplication[3-4]>
<Function test_multiplication[5-4]>
======================== no tests ran in 0.04s ========================
А запустивши скрипт, отримуємо такий результат: 4 failed, 2 passed, 1 warnings in 0.11s
Додаткові завдання
На цьому можна було б закінчити статтю, але для більшої складності я додам у нашу функцію зручніші ідентифікатори, інший розбір даних і маркування кожного окремого тестового випадку.
Отже, одразу з ходу код:
# conftest.py
import os
import yaml
import pytest
def pytest_generate_tests(metafunc):
def generate_id(input_data, level):
level += 1
# Выбираем как это будет выглядеть
INDENTS = {
# level: (levelmark, addition_indent)
1: ('_', ['', '']),
2: ('-', ['[', ']'])
}
COMMON_INDENT = ('-', ['[', ']'])
levelmark, additional_indent = INDENTS.get(level, COMMON_INDENT)
# Если глубже второго уровня - идентификатором становится тип данных
if level > 3:
return additional_indent[0] + type(input_data).__name__ + additional_indent[1]
# Возвращаем простые данные
elif isinstance(input_data, (str, bool, float, int)):
return str(input_data)
# Разбираем список
elif isinstance(input_data, (list, set, tuple)):
# Погружаемся в список, чтобы проверить те данные, что внутри
list_repr = levelmark.join(
[ generate_id(input_value, level=level) \
for input_value in input_data ])
return additional_indent[0] + list_repr + additional_indent[1]
# Ключи словаря переводим в строку
elif isinstance(input_data, dict):
return '{' + levelmark.join(input_data.keys()) + '}'
# Или ничего для ничего
else:
return None
# Пропускаем все функции, у которых нет аргумента test_input
if 'test_input' not in metafunc.fixturenames:
return
# Определяем директорию текущего файла
dir_path = os.path.dirname(os.path.abspath(metafunc.module.__file__))
# Определяем путь к файлу с данными
file_path = os.path.join(dir_path, metafunc.function.__name__ + '.yaml')
# Открываем выбранный файл
with open(file_path) as f:
raw_test_cases = yaml.full_load(f)
# Предусматриваем неправильную загрузку и пустой файл
if not raw_test_cases:
raise ValueError("Test cases not loaded")
# Тут будут наши тест-кейсы
test_cases = []
# Проходим по нашим сырым данным
for case_id, test_case in enumerate(raw_test_cases):
# Ищем список маркеров
marks = [ getattr(pytest.mark, name) for name in test_case.get("marks", []) ]
# Берем идентификатор из данных, либо сгенерируем
case_id = test_case.get("id", generate_id(test_case["test_data"], level=0))
# Добавляем в наш список сгенерированный из тестовых данных pytest.param
test_cases.append(pytest.param(*test_case["test_data"], marks=marks, id=case_id))
return metafunc.parametrize("test_input, expected_result", test_cases)
Відповідно змінюємо те, який вигляд матиме наш YAML-файл:
# test_multiplication.yaml
-
test_data: [1, 2]
id: 'one_two'
-
test_data: [1,3]
marks: ['xfail']
-
test_data: [1,5]
marks: ['skip']
-
test_data: [2,4]
id: "it's good"
marks: ['xfail']
-
test_data: [3,4]
marks: ['negative']
-
test_data: [5,4]
marks: ['more_than']
Тоді опис зміниться на:
<Module test_script.py>
<Function test_multiplication[one_two]>
<Function test_multiplication[1_3]>
<Function test_multiplication[1_5]>
<Function test_multiplication[it's good]>
<Function test_multiplication[3_4]>
<Function test_multiplication[5_4]>
А запуск буде: 2 failed, 1 passed, 1 skipped, 1 xfailed, 1 xpassed, 2 warnings in 0.12s
P.S.: warnings — оскільки самописні маркери не записані в pytest.ini
На розвиток теми
Готовий обговорити в коментарях питання на кшталт:
-
як краще писати YAML-файл?
-
у якому форматі зручніше зберігати тестові дані?
-
що додатково необхідно тест-кейсу на стадії генерації?
-
чи потрібні ідентифікатори кожному випадку?