Тяжелые времена требуют тяжелых решений. Не всегда архитектура приложений и тестового окружения идеальная. Они могут быть из рук вон плохими и не гибкими. А для их тестирования приходится переступать через гордость тестировщика и нарушать базовые принципы тестирования программного обеспечения.

Проблема, которой нет

Во-первых, каждый уважающий себя тестировщик должен знать, что такое “пирамида тестирования ПО”. А именно, что количество простых тестов, начиная от модульных, должно быть значительно больше, чем последующих API, затем UI и E2E тестов. Это объясняется тем, что стоимость разработки, выполнения и поддержки таких тестов ниже, они работают быстрее, да и, как правило уже на самом раннем этапе разработки и тестирования позволяют отлавливать большую часть ошибок. При этом, самые длинные тесты, которые последовательно, следуя бизнес-логике покрывают длинную последовательность действий и состояний - E2E обычно составляют самую маленькую часть тестирования. В первую очередь потому, что все их составляющие уже были (часто неоднократно) проверены меньшими и более простыми тестами. Во вторую, что действительно критичная бизнес-логика, коей и должны являться E2E тесты, как правило - это всего пара сценариев, которые, как мне кажется, с большой долей вероятности будут составлять ваш "дымовой набор" (smoke) для предрелизного тестирования (acceptance regression).

Ещё раз про пирамиду тестирования
Ещё раз про пирамиду тестирования

Чтобы слопать слона по кусочкам, каждый кусочек должен иметь возможность быть проверенным независимо. Обычно это так и есть - для функции пишутся несколько юнит-тестов, перебирающих параметры (например используя области эквивалентности и гранитные значения). Для интеграционных тестов (когда мы проверяем несколько компонентов\функций), скорее всего, вы будете использовать заглушки и моки. Для того, чтобы проверить состояние системы и её бизнес-логику, формат ответов и дизайн (например для проверки покрытия используя графы переходов или таблицу решений), скорее всего уже на уровне UI, для получения нужного состояния наверняка нужно будет предварительно как предусловие подёргать несколько API эндпоинтов и скормить системе нужные данные, чтобы получить нужное состояние. И только в совсем редких случаях как E2E сценарий мы будем писать тест, который пройдёт от логина до получения чуть ли не физического результата - зажженного светофора, выдачи денег банкоматом, или просто получения сообщения “прожиг успешно завершен неудачей”.

При этом, скорее всего, тесты - автоматизированные или даже ручные, если у вас несколько тестировщиков в команде, вы будете выполнять независимо. Чтобы состояние и данные не мешали выполнению тестов, тесты должны быть изолированными и атомарными. В худшем случае - это несколько тестовых изолированных окружений. В лучшем - отдельные компоненты, которые могут получать независимые состояния для своего контекста (функции, сессии, пользователя и прочая). При этом начало выполнения любого теста не должно зависеть от результатов выполнения другого. Ни из-за состояния системы, ни, тем более, от логики теста, который вы пишите. Это собственно и есть принцип изоляции. Тогда тесты будут маленькими, простыми, соответствовать принципу - “один тест - одна проверка” (то есть выполнять ещё один принцип - атомарности), и не должны будут проверять несколько раз, что мы перешли из состояния А в Б, чтобы потом из него попасть в состояние В, в котором будем проводить тестирования перехода в состояние Г.

Где-то в замечательных компаниях
Где-то в замечательных компаниях

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

Но, скорее всего, на каком-то этапе всё может пойти не так. И теория разойдётся с практикой в какой-то части. А то и целиком и полностью.

Проблема, которая есть

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

Допустим, у вас есть тестовое окружение с сотней тестов, которые выполняются в изолированной среде - за несколькими брэндмауэрами (как то, на котором крутим тесты, как то, где развёрнута система и та, которая тесты запускает - наш CI сервер), из-за которых происходит обращение к одной из платформ облачного тестирования. Если вы работали с такими системами, то, наверное, знаете, что развёртывание вируталки занимает какое-то время, поэтому не сильно резонно для каждого теста рвать соединение и пересоздавать его заново. Более того, даже если использовать только несколько развёрнутых виртуалок, скорость их работы оставляет желать лучшего. Ну а из-за нагромождений внутренних сетей, а так же разных регионов, эта связка, так скажем, начинает работать далеко не со скоростью света.

Если ещё транслируем видео и записываем тесты через огороженные системы...
Если ещё транслируем видео и записываем тесты через огороженные системы...

Что ж, запуск тестов становится медленнее, но всё же, никто нам не мешает сбрасывать и выставлять состояние для каждого теста внутри такой виртуалки. Да, но… система написана так, что у неё отсутствуют тестовые ручки, которые можно дёрнуть и выставить нужное состояние. Более того, для UI тестов нельзя просто перейти по нужному URL до нужной формы, а нужно обязательно покликать несколько экранов. Это всё занимает время, а в условиях тестового окружения подготовка к одному тесту может занять 1-2 минуты. Это уже звучит как огромная проблема, особенно, если мы пытаемся писать тесты хорошо, изолированно и атомарно, и следующая проверка - это, скажем ввод символа в поле. Катастрофа, заставляющая нас либо выкидывать такие тесты, либо ждать результатов часами.

Тут, похоже, пора переступать через свою гордость тестировщика, и лепить несколько проверок в один тест. Скажем, теперь тест будет заполнять двадцать полей и кликать по кнопке, чтобы перейти на другой экран. Думаю, вы понимаете, что во время ввода в поле, перекдывания чекбокса или переключателя может пойти что-то не так - отобразиться ошибка, например, потеряться текст, а при взаимодействии с другим компонентом состояние может опять изменится, и мы наверняка получим не совсем валидный результат. Итак, значит всё-таки как-то надо дробить нашу проверку на тесты, но при этом оставляя состояние системы для каждого из тестов, равную предыдущему состоянию. Итак, это мы можем сделать если будем сбрасывать контекст в рамках сессии. Это конечно неплохо, но тогда мы каждый раз будем поднимать виртуалку под сессию, объединяющую последовательность тестов. Пытаясь восстановить атомарность мы будем терять в производительности. Тогда есть ещё одна альтернатива - изолировать состояние по файлам, а ещё лучше - обернув тесты в тест-классы. Так скажем, SRP отдадим от уровня тестовой функции тестовому классу. Ведь, не случится ничего плохого, правда же? Да, я забыл сказать, что я пишу на python и использую pytest, так что будем говорить о нём дальше.

Проблема, которую мы создали

Итак, хороший и красивый атомарный тест, с которым вы обычно работаете выглядит следующим образом:

"""This module has no class, only test functions"""


def test_no_class_first():
    """This test always passes"""
    assert True


def test_no_class_second():
    """This test always fails"""
    assert False

Однако, для моего частного случая с моей неидеальной системой и командой мы решили использовать тест-классы. Они будут выглядеть примерно так:

"""This is a basic test"""


class TestBasic:
    """This is a basic test"""

    def test_basic(self):
        """This is basic test 1"""
        assert True

    def test_basic2(self):
        """This is basic test 2"""
        assert False

    def test_basic3(self):
        """This is basic test 3"""
        assert True

Лично у меня от такого идёт кровь из глаз, потому что я представлю какие проблемы это породит. Хотя выглядит, на первый взгляд неплохо. Но это на первый взгляд. Если вы позовёте тесты как обычно:

pytest tests

То тесты выполнятся последовательно, и, если в них была какая-то взаимосвязь, то она сохранится. Однако, если вы попробуете вызвать тесты в несколько потоков (pytest-xdist’ом), то последовательность перемешается.

pytest tests -n=auto

Чтобы этого избежать, нужно указать, как именно группировать тесты. Для этого нужно не забыть указать способ группировки.

pytest test -n=auto --dist loadscope 

Теперь, представим, что у нас двадцать шагов, зависимых друг от друга, и нам придётся пройтись по всем, даже есть один из первых тестов упал. Но в этом нет уже смысла, так как состояние было уже нарушено. Поэтому нам надо как-то в том случае реализовать логику “fail fast” - падать быстро. Для этого нам немного предстоит вмешаться в логику pytest’a и реализовать:

"""conftest.py"""

import pytest


def pytest_runtest_makereport(item, call):
    if "incremental" in item.keywords:
        if call.excinfo is not None:
            parent = item.parent
            parent._previousfailed = item

def pytest_runtest_setup(item):
    previousfailed = getattr(item.parent, "_previousfailed", None)
    if previousfailed is not None:
        pytest.xfail("previous test failed (%s)" % previousfailed.name)

Теперь те тест-классы, которые нужно быстро завалить, нужно предварительно пометить:

"""This is incremental test class"""

import pytest


@pytest.mark.incremental
class TestMarkedFailFast:
    """"This test class will fail fast"""
    
    def test_will_pass(self):
        """"This test will pass"""
        assert True

    def test_will_fail(self):
        """"This test will fail"""
        assert False

    def test_wont_run(self):
        """"This test won’t run"""
        assert True

Ещё одна проблема, которую мы создали - это бессмысленность использовать плагин pytest-rerunfailures. Если мы его решим использовать, то тест будет перезапущен вне контекста класса с совсем не тем состоянием, которое мы ожидали. Можно поломать ещё охапку копий на тему, насколько стоит использовать перезапуски или нет. В конце концов, пару падений мы переживём и перед релизом вручную перезапустим. Если падений много, возможно, что-то критичное поломалось, и мы быстро идентифицируем ошибку, а перезапускать придётся всё равно большую часть тестов - тогда уж можно и всё. Но если у вас тестов много - тысячи, а нестабильность всё же случается, и время жалко, если тест, конечно не регулярно нестабильно падает, то всё-таки лучше делегировать перезапуск машине. Но в нашем случае… таких вариантов, получается нет?

Проблема, которую я решил

Итак, для решения этой проблемы я написал плагин pytest-rerunclassfailures.

Чтобы его установить и начать пользоваться, просто ставим его через pip:

pip install pytest-rerunclassfailures

Чтобы запустить тесты с реранами, достаточно передать параметр —rerun-class-max с каким-нибудь числом реранов.

pytest tests --rerun-class-max=2

Ну или добавить явным образом в pytest.ini:

[pytest]
plugins = pytest-rerunclassfailures
addopts = --rerun-class-max=3

Также можно выставить дополнительные параметры, вроде задержки между реранами или тип логирования:

Параметр

Описание

По умолчанию

--rerun-class-max=N

количество перезапусков упавшего тест-класса

0

--rerun-delay=N

задержка между перезапусками в секундах

0.5

--rerun-show-only-last

показывать результаты только последнего перезапуска - в логе не будет результатов с “RERUN”, только последний, финальный запуск с итоговом результатом

Не передаётся

-hide-rerun-details

убрать детали перезапусков (ошибки и трейсбек) в терминале

Не передаётся

YTHONPATH=. pytest -s tests -p pytest_rerunclassfailures --rerun-class-max=3 --rerun-delay=1 --rerun-show-only-last
Результат запуска тестов со включенным плагином
Результат запуска тестов со включенным плагином

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

Немного ещё плохого дизайна
"""Test class with function (fixtures) attributes"""

from random import choice

import pytest

random_attribute_value = choice((42, "abc", None))


@pytest.fixture(scope="function")
def function_fixture(request):
    """Fixture to set function attribute"""
    request.cls.attribute = "initial"
    return "initial"


@pytest.fixture(scope="function")
def function_fixture_secondary(request):
    """Fixture to set function attribute"""
    request.cls.attribute = "secondary"
    return "secondary"


class TestFunctionFixturesAttributes:
    """Test class with function params attributes"""

    def test_function_fixtures_attribute_initial(self, function_fixture):  # pylint: disable=W0621
        """Test function fixture attribute at the beginning of the class"""
        assert self.attribute == "initial"
        assert function_fixture == "initial"

    def test_function_fixtures_attribute_recheck(self, function_fixture_secondary):  # pylint: disable=W0621
        """Test function fixture attribute after changing attribute value"""
        assert self.attribute == "secondary"  # type: ignore  # pylint: disable=E0203
        assert function_fixture_secondary == "secondary"
        self.attribute = random_attribute_value  # type: ignore  # pylint: disable=attribute-defined-outside-init
        # attribute is changed, but fixture is not
        assert self.attribute == random_attribute_value
        assert function_fixture_secondary == "secondary"

    def test_function_fixtures_attribute_forced_failure(self):
        """Test function fixture attribute to be forced failure"""
        assert False

Очень кратко о технической реализации

Для того, чтобы иметь возможность перехватывать и перезапускать тесты тест-класса, я вмешиваюсь в pytest_runtest_protocol и перехватываю управление, если это тест-класс:

@pytest.hookimpl(tryfirst=True)
def pytest_runtest_protocol(
    self, item: _pytest.nodes.Item, nextitem: _pytest.nodes.Item  # pylint: disable=W0613
) -> bool:

Далее получаем тест-класс, и находим его наследников - тест-функции:

parent_class = item.getparent(pytest.Class)
for i in items[items.index(item) + 1 :]:
    if item.cls == i.cls:  # type: ignore
        siblings.append(i)

и затем выполняем стандартный протокол тестирования для каждого наследника последовательно:

for i in range(len(siblings) - 1):
    # Before run, we need to ensure that finalizers are not called (indicated by None in the stack)
    nextitem = siblings[i + 1] if siblings[i + 1] is not None else siblings[0]
    siblings[i].reports = runtestprotocol(siblings[i], nextitem=nextitem, log=False)

И, финально, после определения статуса теста (сколько мы его раз должны были перезапустить и выставить результат или rerun), отправляем результаты обратно:

item.ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location)
for index, rerun in enumerate(test_class[item.nodeid]):
    self.logger.debug("Reporting node results %s (%s/%s)", item.nodeid, len(test_class[item.nodeid]), index)
    for report in rerun:
        item.ihook.pytest_runtest_logreport(report=report)
item.ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location)

Если нужно перезапустить тест-класс, то обязательно делаем очистку, сбрасываем фикстуры и пересоздаем тест-класс в его изначальном виде.

В заключение

На этом всё. Надеюсь, мой плагин поможет вам немного, когда вы работаете с плохими архитектурными решениями, с плохим кодом и тестами. Если этот материал был вам полезен, то не забудьте поставить лайк этому посту, написать комментарий, а так же, если воодушевитесь - поделиться монеткой. Оригинал статьи тут.

Комментарии (4)


  1. danilovmy
    21.08.2024 16:19

    ...тесты выполнятся последовательно, и, если в них была какая-то взаимосвязь, то она сохранится. Однако, если вы попробуете вызвать тесты в несколько потоков, то последовательность перемешается.

    А автору не приходило в голову, что, именно потому, unt-тесты должны быть независимы. Ну или перестаем называть их unit-ами и начинаем лечить сифилис писать независимые unit тесты.

    Двадцать тестов, зависимых друг от друга, придётся пройтись по всем, даже есть один из первых тестов упал

    Любопытно, как можно продолжать тестировать если стейт ошибочный, или суть не ошибку найти, а тесты запустить?

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


    1. wwakabobik Автор
      21.08.2024 16:19
      +1

      Я не уверен, что вы внимательно читали статью. Во-первых, это же не про юнит-тесты в основном, хотя и может их включать. Во-вторых, замечательно когда всё легко и правильно, но не всегда это на практике возможно.

      Любопытно, как можно продолжать тестировать если стейт ошибочный

      В общем случае - да, поэтому я и даю уточнение, что имеет смысл падать быстро. И для каждого теста нужно выставлять этот стейт независимо. Однако, опять-таки, если кривая реализация, что мы какую-либо форму\стейт открываем по пять минут, то, возможно, имеет смысл пройтись по компонентам формы, если мы уверены, что ошибка в одном из них не имеет зависимостей с другими компонентами. Не сильно уверенное заявление, но иногда, если на одной чаше весов проверить 20-30 элементов разом, а на другой на это потратить час - имеет смысл. Я всячески осуждаю, но, повторюсь, реальная жизнь может отличаться от правильной теории.

      если сам подход к тестированию соблюдается

      Ещё раз, большая вводная часть статьи как раз этому посвящена. В идеальном мире, да, так и должно быть. Но, наверное и не было бы этой статьи, если был бы мир идеальным.
      >>>>

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


  1. SaM1808
    21.08.2024 16:19

    Надо уходить от unit тестов

    Заменяем юнит тесты статическим анализом
    Заменяем юнит тесты статическим анализом


    1. wwakabobik Автор
      21.08.2024 16:19
      +1

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