Привет, Хабр!
Меня зовут Владислав Тимашенков, я занимаюсь автоматизацией тестирования в ГК InfoWatch. Мы разрабатываем DLP-систему для защиты контента и предотвращения утечек информации.
Специфика продуктов InfoWatch состоит в анализе самых разнообразных данных. Поэтому практически каждый автотест требует подхода с широким набором параметров. Для нашей команды хорошим решением стало вынести генерацию параметров pytest.mark.parametrize в отдельный компонент, который собирает данные из файловой коллекции проекта.
Существует множество способов параметризировать тестовые функции. Эта статья — пример изящной параметризации Pytest для сценариев, основанных на файловой коллекции проекта с большим количеством данных. Расскажем про наш опыт решения такой задачи.
Параметризация Pytest позволяет сделать из теста каркас, шаблон, который принимает данные для конфигураций, для assert и т.д.
Пример из документации Pytest:
@pytest.fixture def fixt(request): return request.param * 3 @pytest.mark.parametrize("fixt", ["a", "b"], indirect=True) def test_indirect(fixt): assert len(fixt) == 3
Для простых сценариев такая параметризация сработает, но что если нам требуется нечто большее, чем захардкоженные списки?
Абстрагируемся от Pytest как от фреймворка и посмотрим на pytest.mark.parametrize как на объект-декоратор, принимающий набор аргументов.
pytest.mark.parametrize( argnames, # str | Sequence[str] argvalues, # Iterable[Sequence[Any] | Any] indirect, # bool | Sequence[str] ids, # Iterable | Callable | None scope, # str | None )
Под капотом pytest.mark.parametrize — это объект с методом call, который принимает аргументы и возвращает декоратор тестовой функции. Его параметры можно передать через **kwargs, распаковав словарь формата {param_name: your_value, ...}. Вот тут и начинается самое интересное.
Опишу пример на своей недавней задаче. Автотест проверяет срабатывание определенной настройки системы при перехвате разных событий (система умеет определять передачу данных, ввод на клавиатуре, копирование файлов, но я создаю эти события через внутренние инструменты тестирования).
Требования к параметризации:
Тест параметризуется данными из файловой коллекции парой: файл с событием — файл с ожидаемыми данными
1 параметр = 1 пара = 1 директория
ID параметра = путь к директории (.../setting_name/positive/setting_action)
Директории с параметрами должны иметь произвольную глубину вложенности
Лаконичная и простая в поддержке реализация
Таким образом, у нас набирается список логик:
Обойти файловую коллекцию
Найти целевые директории
Сформировать параметры: прочитать один файл, собрать путь к другому файлу
Сформировать id параметров
Собрать словарь в формате для Pytest.
При этом тест состоит всего из трёх шагов: отправить данные, запросить результаты анализа, увидеть в актуальном результате ожидаемый результат.
Мы решили эту задачу следующим образом:
import os from pathlib import Path import pytest def example_parametrizer(path: Path) -> dict: # Создаем пустые списки для хранения значений id и параметров тестов argvalues = [] ids = [] # Рекурсивный обход директории, начиная с указанного пути. # os.walk возвращает кортежи (путь_к_директории, список_поддиректорий, список_файлов) for dirpath, _, filenames in os.walk(path): dirpath = Path(dirpath) # Если файл expected.yaml в директории, то это нужная нам папка с данными if "expected.yaml" in filenames: # Создание пути к JSON-файлу для создания события dump_path = dirpath / "file_for_creating_event.json" # Чтение и парсинг YAML файла с ожидаемыми настройками expected_settings = some_yaml_reader(dirpath / "expected.yaml") # Добавление кортежа с отправляемыми и ожидаемыми данными в список значений argvalues.append((dump_path, expected_settings)) # Добавление относительного пути директории как идентификатора теста ids.append(os.path.relpath(dirpath, path)) # Возврат словаря с параметрами для декоратора pytest.mark.parametrize return { "argnames": ("dump_path", "expected_settings"), "argvalues": argvalues, "ids": ids, }
Структура файловой коллекции такова:
automation_file_collection
└── base_test_name
├── some_preconditions …
└── test_cases
├── negative …
└── positive
└── param_for_setting1
├── file_for_creating_event.json
└── expected.yaml
И используется example_parametrizer() следующим образом:
# Путь к директории с тест-кейсами для положительных сценариев test_cases_path = automation_file_collection / "test_cases" / "positive" # Декоратор для параметризации теста создает тест-кейсы, **kwargs используется для распаковки словаря в именованные аргументы @pytest.mark.parametrize(**example_parametrizer(test_cases_path)) def test_detection_setting(dump_path, expected_settings): """ Описание теста ... """ # создаем событие event_data = creating_event_tool(dump_path) # получаем актуальные данные о работе целевой настройки actual_settings: set = set(event_data.model.settings) # сравниваем актуальные данные и ожидаемые из параметризации assert set(expected_settings).issubset( actual_settings ), f"Ожидаемые настройки {expected_settings} не найдены в событии {event_data}"
Пример вывода:
test_detection_setting.py::test_detection_setting[param_for_setting1] PASSED test_detection_setting.py::test_detection_setting[param_for_setting2] PASSED test_detection_setting.py::test_detection_setting[param_for_setting3] PASSED
Что мы получили по итогу?
Тест стал шаблоном, в его ответственность входит только выполнение действий, он не зависит от данных.
Параметры набираются из любых папок в коллекции по указанному пути, а относительный путь — понятный идентификатор теста. Чтобы создать новый тест-кейс, нужно лишь добавить файлы в коллекцию.
Реализация основана на базовых инструментах Python и не требует неочевидных и непопулярных методов Pytest.
Аналогичное решение применимо и к @pytest.fixture(). Под капотом мы увидим параметры scope, params (с поддержкой ids) и autouse. И можем написать такой же параметризатор, который вернет словарь с ключами для фикстуры:
def fixture_parametrizer(): ids = [] params = [] # Ваша логика для генерации параметров params.append(...) ids.append(...) return {"params": params, "ids": ids}
Также существует альтернативный способ генерировать параметры через pytest_generate_tests. Но он имеет ряд недостатков: логика параметризации выносится из теста, усложняется инфраструктура тестов и снижается читаемость и простота поддержки.
Друзья, если вы ищете простой способ использовать объемную файловую коллекцию в качестве параметров тестов на Python+Pytest, то предлагаем вам свой опыт решения этой задачи.
Такой подход позволяет масштабировать тесты без усложнения самих тестовых функций. А добавление нового тест-кейса сводится к добавлению файлов в коллекцию, сама же логика теста и параметризации остаётся неизменной.
