Параметризація з файлу в py.test

Habr Технічна тема Мова оригіналу: Російською

Реалізація параметризації тестів із відповідних їм YAML-файлів через pytest_generate_tests. Приклад розширюється фільтрацією функцій, читабельними ідентифікаторами й маркерами окремих тестових випадків.

Translated by ChatGPT GPT-6.1 Sol

Відомості про першоджерело

Параметризация из файла в py.test

Архівна публікація. Посади, показники й технічні інструкції наведено станом на дату першої публікації.

У сфері автоматизованого тестування можна зустріти різні інструменти. Так, для написання автотестів мовою 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-файл?

  • у якому форматі зручніше зберігати тестові дані?

  • що додатково необхідно тест-кейсу на стадії генерації?

  • чи потрібні ідентифікатори кожному випадку?