Как накапливать состояние по тестам в 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 будет присутствует, когда другой класс работает.
Я думаю, что каждый из этих подходов, обсужденных таким образом, имеет свои достоинства и намерен использовать каждый подход там, где он подходит лучше всего.