Привет, Хабр!

Меня зовут Владислав Тимашенков, я занимаюсь автоматизацией тестирования в ГК 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, то предлагаем вам свой опыт решения этой задачи.

Такой подход позволяет масштабировать тесты без усложнения самих тестовых функций. А добавление нового тест-кейса сводится к добавлению файлов в коллекцию, сама же логика теста и параметризации остаётся неизменной.