В области автоматического тестирования можно встретить разные инструменты, так, для написания авто-тестов на языке 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 файл?
- в каком формате удобнее хранить тестовые данные?
- что дополнительно необходимо тест-кейсу на стадии генерации?
- нужны ли идентификаторы каждому кейсу?
Комментарии (8)
conopus
24.10.2019 10:15Ну, и раз есть приглашение к дискуссии: «как лучше писать YAML файл?». У вас в примере (да и у меня в практике) тестовые данные это обычно плоские таблицы, поэтому ответ такой — YAML не нужен. Мне проще использовать старый добрый CSV с названиями колонок. Конфигурации — другое дело, там вполне оправдано.
Aen_Dinalt
25.10.2019 10:31Я не так давно использовал библиотеку ddt (data driven test) для решения похожей задачи
https://ddt.readthedocs.io/en/latest/
С декораторами код получается читаемый, имена тестов в отчёте генерируются на основе данных, данные в отдельном модуле в виде словарей или списков.
amarao
Вы заменили идиоматический питон (pytest более питонический чем сам питон) на птичий язык новоизобретённого DSL'я. Если ваши тесты заменить на "простой pytest" (даже без фикстур и параметризации), то:
1) Читаемость повысится
2) Вероятность что-то сломать кратно понизится.
3) Вероятность тонких багов в тестах и фикстурах значительно снизится.
Цель тестов — быть настолько простыми, чтобы баги в тестах можно было ловить глазами. Это не всегда можно, и некоторые тесты требуют отвратительно большого количества кода на подготовку, но стремиться надо к тестам вида
Т.е. все тонкости pytest_generate_tests, параметризации фикстур и т.д. право на существование имеют, но не как best practices, а "от безысходности".
dmytrohoi Автор
Дело в том, что на первый план, в этом случае, была выведена функциональность и вариативность тестов с большим объемом входящих тестовых данных.
Конечно, я понимаю что тестирование должно быть простым, и тесты должны быть такими, чтобы "баги ловились глазами", но в данном случае проблемы с отловкой багов могут возникнуть лишь в случае неправильного подхода к подготовке тестовых данных.
Кроме того, стоит отметить что на тесты где функционал выноса данных за рамки этот код никак не повлияет, если правильно подойти к пункту фильтрации.
conopus
Голосовать не могу, поддержу amarao комментарием. О metafunc напомнили — спасибо, но лучше в реальности такой огород не городить. У меня очень хороший критерий полезности «наворотов» был: коллега мануальный тестировщик, которого я обучал. Если он понял как работает, то можно использовать.
nomhoi
Если большие наборы данных и они как-то генерируются, то, скорее всего, лучше будет использовать pickle.