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


  1. amarao
    23.10.2019 21:36
    +1

    Вы заменили идиоматический питон (pytest более питонический чем сам питон) на птичий язык новоизобретённого DSL'я. Если ваши тесты заменить на "простой pytest" (даже без фикстур и параметризации), то:


    1) Читаемость повысится
    2) Вероятность что-то сломать кратно понизится.
    3) Вероятность тонких багов в тестах и фикстурах значительно снизится.


    Цель тестов — быть настолько простыми, чтобы баги в тестах можно было ловить глазами. Это не всегда можно, и некоторые тесты требуют отвратительно большого количества кода на подготовку, но стремиться надо к тестам вида


    test_2x2_is_4():
       assert mul(2, 2) == 4

    Т.е. все тонкости pytest_generate_tests, параметризации фикстур и т.д. право на существование имеют, но не как best practices, а "от безысходности".


    1. dmytrohoi Автор
      23.10.2019 22:30

      Дело в том, что на первый план, в этом случае, была выведена функциональность и вариативность тестов с большим объемом входящих тестовых данных.


      Конечно, я понимаю что тестирование должно быть простым, и тесты должны быть такими, чтобы "баги ловились глазами", но в данном случае проблемы с отловкой багов могут возникнуть лишь в случае неправильного подхода к подготовке тестовых данных.


      Кроме того, стоит отметить что на тесты где функционал выноса данных за рамки этот код никак не повлияет, если правильно подойти к пункту фильтрации.


    1. conopus
      24.10.2019 10:01

      Голосовать не могу, поддержу amarao комментарием. О metafunc напомнили — спасибо, но лучше в реальности такой огород не городить. У меня очень хороший критерий полезности «наворотов» был: коллега мануальный тестировщик, которого я обучал. Если он понял как работает, то можно использовать.


      1. nomhoi
        24.10.2019 15:55

        Если большие наборы данных и они как-то генерируются, то, скорее всего, лучше будет использовать pickle.


  1. conopus
    24.10.2019 10:15

    Ну, и раз есть приглашение к дискуссии: «как лучше писать YAML файл?». У вас в примере (да и у меня в практике) тестовые данные это обычно плоские таблицы, поэтому ответ такой — YAML не нужен. Мне проще использовать старый добрый CSV с названиями колонок. Конфигурации — другое дело, там вполне оправдано.


  1. p45tor
    24.10.2019 10:31

    В статье я задействую чистый Python

    Для работы с yaml вы используете стороннюю библиотеку, стоит указать какую. Указание версий для Python и pytest, тоже будет не лишним.


    1. dmytrohoi Автор
      24.10.2019 10:32

      Согласен, действительно. Поправил этот момент.


  1. Aen_Dinalt
    25.10.2019 10:31

    Я не так давно использовал библиотеку ddt (data driven test) для решения похожей задачи
    https://ddt.readthedocs.io/en/latest/
    С декораторами код получается читаемый, имена тестов в отчёте генерируются на основе данных, данные в отдельном модуле в виде словарей или списков.