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


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


«Капитан, у нас много фикстур и появляются глобалы»


Перегрузка тестовых директорий фикстурами это вполне логичное следствие использования концепта который заложен в 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 устройств.