Передавання динамічних об’єктів від setup до тестової функції в py.test

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

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

Translated by ChatGPT GPT-6.1 Sol

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

Передача динамических объектов от setup к тестовой функции в py.test

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

У великих проєктах у якийсь момент виникає ситуація, коли тестів на проєкті вже багато й паралельно розвивається власний високорівневий фреймворк. Фреймворк у цьому випадку — як обгортка над функціями об’єкта тестування й можливостями різних інструментів, які використовуються на проєкті. Крім того, усі папки заповнені фікстурами, багато з яких використовуються лише в одному тестовому файлі.

У цей прекрасний момент виникають деякі проблеми. Про одну з них я вже писав, це реалізація зручної параметризації, наприклад із файлу. Про наступну, з найбільш злощасних, поговоримо в цій статті.

«Капітане, у нас багато фікстур і з’являються глобальні змінні»

Перевантаження тестових директорій фікстурами — це цілком логічний наслідок використання концепції, закладеної в py.test, але іноді такий підхід виходить за межі прийнятного. Крім того, ми часто можемо спостерігати в тестах конструкції, покликані визначити, яку інформацію треба взяти тесту в окремому конкретному випадку, або бажання спочатку перевірити можливість подальшого проходження тесту ще на стадії підготовки середовища.

Найбільша помилка в ситуації, коли ці конструкції передаються з певної setup-фікстури й проходять уздовж усього тесту, — використовувати global-змінні. Я стикався з подібними тяжкими випадками, і ця ідея спадає на думку однією з перших.

Тут варто згадати, що концепція фікстур дає змогу уникнути такого псування чистоти коду, але також додає ще додаткові рівні абстракції та безліч посилань. У крайньому разі можна завести жахливу звичку розпаковувати результат фікстури, але тоді ми псуємо логи, оскільки в нас немає поділу на Setup, Run і Teardown, і додатково ускладнюємо код у момент розпакування результатів або плодимо численні скорочення.

Розгляньмо на прикладах і почнімо з жахливого:

“Фікстури й global”

import pytest

@pytest.fixture(autouse=True)
def setup(create_human, goto_room, goto_default_position, choose_window, get_current_view):
    global human
    global window

    # Сделаем подготовительные действия
    desired_room = 1 # Можем этот момент параметризировать, в зависимости от задачи
    human = create_human("John", "Doe") # Допускаем что один и тот же человек во всех случаях
  
    # Если что-то пошло не по плану, то дальше нет смысла продолжать
    assert goto_room(human, desired_room), "{} не дошел в комнату {}".format(human.full_name, desired_room)
    
    # Выбираем случайное окно
    window = choose_window(desired_room)
    view = get_current_view(window)
    assert view, "В окне {} ничего нет".format (window)
    
    yield
    # В Teardown вернёмся в начальное место
    goto_default_position(human)

@pytest.mark.parametrize(
    "city, expected_result",
    [
        ("New York", False), 
        ("Berlin", False),
        ("Unknown", True)
    ]
)
def test_city_in_window(city, expected_result):
    """Проверим что за вид у нас за окном."""
    window_view = human.look_into(window)
    recognized_city = human.recognize_city(window_view)
    assert (recognized_city == city) == expected_result, "Мы увидели город которого нет"

У результаті:

  • Є початкові перевірки

  • Є злощасний global

“Розпакування в усій красі”

import pytest

@pytest.fixture
def setup(create_human, goto_room, goto_default_position, choose_window, get_current_view):
    # Сделаем подготовительные действия
    desired_room = 1 # Можем этот момент параметризировать, в зависимости от задачи
    human = create_human("John", "Doe") # Допускаем что один и тот же человек во всех случаях
  
    # Если что-то пошло не по плану, то дальше нет смысла продолжать
    assert goto_room(human, desired_room), "{} не дошел в комнату {}".format(human.full_name, desired_room)
    
    # Выбираем случайное окно
    window = choose_window(desired_room)
    view = get_current_view(window)
    assert view, "В окне {} ничего нет".format (window)
    
    yield { "human": human, "window": window}

    # В Teardown вернёмся в начальное место
    goto_default_position(human)

@pytest.mark.parametrize(
    "city, expected_result",
    [
        ("New York", False), 
        ("Berlin", False),
        ("Unknown", True)
    ]
)
def test_city_in_window(setup, city, expected_result):
    """Проверим что за вид у нас за окном."""
    data = setup

    window_view = data["human"].look_into(data["window"])
    recognized_city = data["human"].recognize_city(window_view)
    assert (recognized_city == city) == expected_result, "Мы увидели город которого нет"

У результаті:

  • Незрозуміле скорочення для результату фікстури setup

  • Розпакування малоінформативне або може зайняти пару, а то й десяток рядків

Якщо на маленькому прикладі це не виглядає так критично, то коли тест розростається до 400+ рядків, це починає впадати в око.

Маленька хитрість про класи

То як же вчинити, якщо ми бачимо перед собою 8 фікстур у setup, із якого нам треба дістати і кілька фікстур після використання для попередніх налаштувань тесту, і дані, які ми обробимо та провалідуємо поза межами нашого випадку?

Це дзвіночок — настав час узяти екземпляр класу й передати все через нього. Сам екземпляр за нас створить py.test, тому нам треба буде просто вказати його атрибути.

Удосконалимо наш приклад:

import pytest

class TestWindowView:
    @pytest.fixture
    def setup(self, create_human, goto_room, goto_default_position, choose_window, get_current_view):
        # Сделаем подготовительные действия
        desired_room = 1 # Можем этот момент параметризировать, в зависимости от задачи
        self.human = create_human("John", "Doe") # Допускаем что один и тот же человек во всех случаях
  
        # Если что-то пошло не по плану, то дальше нет смысла продолжать
        assert goto_room(self.human, desired_room), "{} не дошел в комнату {}".format(human.full_name, desired_room)
    
        # Выбираем случайное окно
        self.window = choose_window(desired_room)
        view = get_current_view(self.window)
        assert view, "В окне {} ничего нет".format (self.window)
    
        yield

        # В Teardown вернёмся в начальное место
        goto_default_position(self.human)

    @pytest.mark.parametrize(
        "city, expected_result",
        [
            ("New York", False), 
            ("Berlin", False),
            ("Unknown", True)
        ]
    )
    def test_city_in_window(self, setup, city, expected_result):
        """Проверим что за вид у нас за окном."""
        window_view = self.human.look_into(self.window)
        recognized_city = self.human.recognize_city(window_view)
        assert (recognized_city == city) == expected_result, "Мы увидели город которого нет"

У результаті:

  • Чисте й зрозуміле передавання потрібних об’єктів

  • Жодних розпакувань і global

Про доцільність методу

З погляду цілісності взятий приклад здається незрозумілим і повністю замінним іншими підходами, але в цьому випадку цей приклад варто розглядати лише як демонстрацію подібної можливості та можливість оцінити лаконічність кінцевого коду.

По суті ж, ідеться суто про динамічні об’єкти для подальшого тестування. Це також стосується зручних скорочень для подальшого використання в тих випадках, коли стан середовища не залежить від вхідних параметрів і багато в чому залежить від зовнішніх чинників.

На мій погляд, найбільш відповідними сценаріями використання описаного підходу є робота з Android/iOS застосунками через Appium і тестування IOT/Embedded пристроїв.