Как накапливать состояние по тестам в py.test

В настоящее время у меня есть проект и тесты, подобные этим.

class mylib:
    @classmethod
    def get_a(cls):
        return 'a'

    @classmethod
    def convert_a_to_b(cls, a):
        return 'b'

    @classmethod
    def works_with(cls, a, b):
        return True

class TestMyStuff(object):
    def test_first(self):
        self.a = mylib.get_a()

    def test_conversion(self):
        self.b = mylib.convert_a_to_b(self.a)

    def test_a_works_with_b(self):
        assert mylib.works_with(self.a, self.b)

С py.test 0.9.2 эти тесты (или аналогичные) проходят. В более поздних версиях py.test, test_conversion и test_a_works_with_b завершаются с ошибкой "TestMyStuff не имеет атрибута a".

Я предполагаю, что это потому, что в более поздних сборках py.test для каждого тестируемого метода создается отдельный экземпляр TestMyStuff.

Как правильно написать эти тесты так, чтобы результаты могли быть даны для каждого из шагов в последовательности, но состояние из предыдущего (успешного) теста может (должно) использоваться для выполнения последующих тестов?

5 ответов

Решение

Хорошая практика модульных тестов - избегать накопления состояний в разных тестах Большинство фреймворков модульного тестирования идут на многое, чтобы предотвратить накопление состояния. Причина в том, что вы хотите, чтобы каждый тест стоял отдельно. Это позволяет вам запускать произвольные подмножества ваших тестов и гарантирует, что ваша система находится в чистом состоянии для каждого теста.

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

С py.test вы можете сделать это, явно указав, что хотите поделиться тестовым состоянием. Ваш пример переписан для работы:

class State:
    """ holding (incremental) test state """

def pytest_funcarg__state(request):
    return request.cached_setup(
        setup=lambda: State(),
        scope="module"
    )

class mylib:
    @classmethod
    def get_a(cls):
        return 'a'

    @classmethod
    def convert_a_to_b(cls, a):
        return 'b'

    @classmethod
    def works_with(cls, a, b):
        return True

class TestMyStuff(object):
    def test_first(self, state):
        state.a = mylib.get_a()

    def test_conversion(self, state):
        state.b = mylib.convert_a_to_b(state.a)

    def test_a_works_with_b(self, state):
        mylib.works_with(state.a, state.b)

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

Тем не менее, это немного хрупко, потому что если вы выберете только выполнение "test_conversion" с помощью, например, "py.test -k test_conversion", то ваш тест не пройден, потому что первый тест не был запущен. Я думаю, что какой-то способ сделать инкрементные тесты был бы хорош, так что, возможно, мы в конечном итоге сможем найти абсолютно надежное решение.

HTH, Хольгер

Это, безусловно, работа для приспособлений Pytest: https://docs.pytest.org/en/latest/fixture.html

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

Таким образом, пример настройки прибора для удержания состояния может быть следующим:

import pytest


class State:

    def __init__(self):
        self.state = {}


@pytest.fixture(scope='session')
def state() -> State:
    state = State()
    state.state['from_fixture'] = 0
    return state


def test_1(state: State) -> None:
    state.state['from_test_1'] = 1
    assert state.state['from_fixture'] == 0
    assert state.state['from_test_1'] == 1


def test_2(state: State) -> None:
    state.state['from_test_2'] = 2
    assert state.state['from_fixture'] == 0
    assert state.state['from_test_1'] == 1
    assert state.state['from_test_2'] == 2

Обратите внимание, что вы можете указать область для внедрения зависимости (и, следовательно, состояние). В этом случае я установил сеанс, другой вариант будет module (scope=function не будет работать для вашего варианта использования, так как вы потеряете состояние между функциями.

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

В качестве предупреждения - вы все еще хотите иметь возможность запускать свои тесты в любом порядке (в моем примере это нарушение порядка s, где порядок 1 и 2 приводит к сбою). Однако я не проиллюстрировал это ради простоты.

Чтобы дополнить ответ hpk42, вы также можете использовать pytest-шаги для выполнения инкрементального тестирования, это может помочь вам, в частности, если вы хотите поделиться каким-либо инкрементным состоянием / промежуточными результатами между шагами.

С этим пакетом вам не нужно помещать все шаги в классе (вы можете, но это не обязательно), просто украсьте свою функцию "комплект тестов" @test_steps,

РЕДАКТИРОВАТЬ: есть новый режим "генератор", чтобы сделать его еще проще:

from pytest_steps import test_steps

@test_steps('step_first', 'step_conversion', 'step_a_works_with_b')
def test_suite_with_shared_results():
    a = mylib.get_a()
    yield

    b = mylib.convert_a_to_b(a)
    yield

    assert mylib.works_with(a, b)
    yield

LEGACY ответ:

Вы можете добавить steps_data параметр вашей тестовой функции, если вы хотите поделиться StepsDataHolder возражать между вашими шагами.

Ваш пример тогда напишет:

from pytest_steps import test_steps, StepsDataHolder

def step_first(steps_data):
    steps_data.a = mylib.get_a()


def step_conversion(steps_data):
    steps_data.b = mylib.convert_a_to_b(steps_data.a)


def step_a_works_with_b(steps_data):
    assert mylib.works_with(steps_data.a, steps_data.b)


@test_steps(step_first, step_conversion, step_a_works_with_b)
def test_suite_with_shared_results(test_step, steps_data: StepsDataHolder):

    # Execute the step with access to the steps_data holder
    test_step(steps_data)

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

(Кстати я автор этого пакета;))

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

То, что я в итоге использовал для некоторых своих классов, где сам класс представлял процесс, который накапливал состояние, я сохранял накопленное состояние в самом объекте класса.

class mylib:
    @classmethod
    def get_a(cls):
        return 'a'

    @classmethod
    def convert_a_to_b(cls, a):
        return 'b'

    @classmethod
    def works_with(cls, a, b):
        return True

class TestMyStuff(object):
    def test_first(self):
        self.__class__.a = mylib.get_a()

    def test_conversion(self):
        self.__class__.b = mylib.convert_a_to_b(self.a)

    def test_a_works_with_b(self):
        mylib.works_with(self.a, self.b)

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

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

Другие вопросы по тегам