В области автоматического тестирования можно встретить разные инструменты, так, для написания авто-тестов на языке 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 файл?
- в каком формате удобнее хранить тестовые данные?
- что дополнительно необходимо тест-кейсу на стадии генерации?
- нужны ли идентификаторы каждому кейсу?
