2025, Nov 02 05:17
Фикстура pytest: как один раз посчитать и переиспользовать результат
Как с помощью фикстуры pytest один раз посчитать дорогой результат, сохранить его в pickle в корне проекта и переиспользовать между прогонами и сессиями.
Предварительно вычислить «дорогой» результат и переиспользовать его между запусками тестов — практичный способ ускорить обратную связь. Но есть ловушка: если опираться на ручную инициализацию при первом запуске, автоматизация становится хрупкой. С pytest не нужно включать и выключать фрагменты кода: достаточно поручить фикстуре один раз сгенерировать и сохранить данные, а затем подхватывать их при следующих прогонах.
Что идет не так при ручном подходе
На первый взгляд всё просто: один раз запускаем тяжелый вычислитель, сериализуем результат в pickle, а в тестах его подгружаем. Однако схема ломается, когда приходится помнить о ручном «прогреве» файла или когда относительные пути приводят к появлению дублей в зависимости от того, откуда вызван pytest. Нужна автоматическая инициализация при первом запуске, которая локально сохраняет артефакт и переживает сессии.
Минимальный пример, показывающий проблему
Ниже компактный пример паттерна, где тяжёлая операция должна выполниться один раз, а затем переиспользоваться.
import time
import pickle
class Answer:
"""Represents a solution produced by a time-consuming routine."""
def crunch_hard_task() -> Answer:
time.sleep(1)
return Answer()
def snapshot_result() -> None:
"""Run the solver once and persist the outcome for feature development."""
outcome = crunch_hard_task()
with open('solved.pickle', 'wb') as fh:
pickle.dump(outcome, fh)
def feature_under_test(solved: Answer) -> None:
"""Use the prepared solution."""
print(solved)
def test_feature() -> None:
# snapshot_result() # Это нужно вручную включить при первом запуске.
with open('solved.pickle', 'rb') as fh:
restored = pickle.load(fh)
assert feature_under_test(restored) is None
Почему так происходит
Тесты предполагают, что файл уже существует. Если его нет, они падают или заставляют вас вручную дергать инициализатор. Плюс, хранение pickle по относительному пути означает, что запуск pytest из разных подпапок может породить копии файла в нескольких местах. У этой схемы нет надёжного автоматического «первого запуска» и канонического пути для устойчивого артефакта.
Автоматизируем первый запуск с помощью фикстуры pytest
Фикстуры pytest умеют выполнять произвольную подготовительную логику. Это ровно то, что нужно: проверить наличие pickle, при наличии — загрузить, иначе один раз посчитать, сохранить и вернуть. Чтобы не получать дубли при запуске из вложенных каталогов, следует ссылаться на корень проекта через объект конфигурации pytest.
import pickle
from pathlib import Path
import pytest
class Answer:
"""Represents a solution produced by a time-consuming routine."""
def crunch_hard_task() -> Answer:
# Имитируем затратное вычисление
import time
time.sleep(1)
return Answer()
@pytest.fixture
def solved_artifact(pytestconfig) -> Answer:
# Создаём единое каноническое расположение в корне проекта
pickle_file = pytestconfig.rootpath / "solved.pickle"
if pickle_file.exists():
with pickle_file.open("rb") as fh:
return pickle.load(fh)
print("Solving problem, please be patient")
computed = crunch_hard_task()
with pickle_file.open("wb") as fh:
pickle.dump(computed, fh)
return computed
def feature_under_test(solved: Answer) -> None:
print(solved)
def test_feature(solved_artifact: Answer) -> None:
assert feature_under_test(solved_artifact) is None
Это убирает ручной переключатель. При первом запуске фикстура создаёт и кеширует pickle в базовой директории проекта; при следующих прогонах просто загружает файл. Благодаря привязке пути к корню проекта не появятся лишние копии при запуске тестов из подкаталогов.
Дополнительные варианты
Если нужно, чтобы вычисление происходило только один раз за запуск pytest — независимо от числа тестов, — используйте фикстуру с областью видимости session. Она по‑прежнему выполняется при каждом запуске pytest, но строго один раз на сессию.
@pytest.fixture(scope="session")
def solved_artifact(pytestconfig) -> Answer:
pickle_file = pytestconfig.rootpath / "solved.pickle"
if pickle_file.exists():
with pickle_file.open("rb") as fh:
return pickle.load(fh)
print("Solving problem, please be patient")
computed = crunch_hard_task()
with pickle_file.open("wb") as fh:
pickle.dump(computed, fh)
return computed
В зависимости от стабильности вашей модели данных есть ещё практичный вариант: хранить pickle в системе контроля версий и пересоздавать его отдельно при необходимости. Насколько это уместно, зависит от того, как редко меняется структура.
В pytest также есть встроенная система кеширования, которая позволяет хранить данные между прогонами без ручного управления файлом. См. документацию по объекту config cache: https://docs.pytest.org/en/stable/how-to/cache.html#the-new-config-cache-object
Зачем это нужно
Автоматизация первого запуска делает тесты детерминированными и воспроизводимыми. Она устраняет человеческий фактор, о котором легко забыть, и держит артефакты в одном предсказуемом месте. Это особенно важно, когда тесты разбросаны по подпакетам и запускаются из разных рабочих директорий: хранение состояния в корне проекта предотвращает фрагментацию.
Итоги
Используйте фикстуру pytest, которая проверяет наличие сохранённого артефакта и при необходимости генерирует его на лету. Привязывайте путь хранения к корню проекта через конфигурацию pytest, чтобы файл был общим для всех запусков вне зависимости от текущей рабочей директории. Нужна семантика «один раз на сессию» — задайте scope=session; хотите вовсе не управлять файлами — рассмотрите кеш pytest. А если объект решения крайне стабилен, хранение pickle под контролем версий может быть простым альтернативным путём. В итоге — никаких ручных переключателей, быстрее итерации и предсказуемая, долговечная настройка, которая сохраняется между сессиями.
Статья основана на вопросе на StackOverflow от Paweł Wójcik и ответе от David Maze.