2025, Oct 20 10:17
Надёжное тестирование CLI в Python: пакет и общие модули
Разбираем, почему падают импорты в pytest при тестировании CLI на Python, и предлагаем надёжную схему: пакетная структура, общий модуль, запуск из корня.
Надёжное тестирование CLI в Python часто упирается в проблему, когда тесту нужно импортировать символы из скрипта верхнего уровня и одновременно запускать этот же скрипт через subprocess. Типичный симптом — ModuleNotFoundError в pytest при попытке импортировать по пути модуля скрипта. Дело не в самом pytest, а в структуре проекта и в том, как Python разрешает импорты.
Минимальная конфигурация, воспроизводящая проблему
Рассмотрим такую структуру:
SomeDir
|- script.py
|- TestDir
|  |- test.py
Скрипт отдаёт коды завершения через Enum и завершает работу вызовом sys.exit. Тесты запускают скрипт как подпроцесс и сверяют returncode с Enum. Прямой импорт Enum из скрипта внутри теста падает, потому что корневая папка не является импортируемым пакетом.
Ниже — сокращённая версия, демонстрирующая режим отказа:
# SomeDir/script.py
from enum import Enum
import sys
class ExitSignal(Enum):
    OK = 0
    FAIL = 1
class Runner:
    def execute(self):
        # имитируем некоторую обработку
        sys.exit(ExitSignal.OK.value)
if __name__ == '__main__':
    Runner().execute()
# SomeDir/TestDir/test.py
import subprocess
from script import ExitSignal  # ModuleNotFoundError при запуске из TestDir
def test_cli_exit_status():
    completed = subprocess.run(['python', '../script.py'], capture_output=True)
    assert completed.returncode == ExitSignal.OK.value
Почему импорт не срабатывает
Когда pytest запускается из каталога с тестами, Python не рассматривает родительскую директорию как пакет. Интерпретатор не может разрешить from script import ExitSignal, потому что родительская папка не находится в sys.path как импортируемый пакет и у неё нет признаков упаковки, позволяющих абсолютные или относительные импорты. Быстрые заплатки вроде изменения sys.path могут сработать, но это хрупко и легко сломать.
Надёжный подход: превратите директорию в пакет и вынесите переиспользуемую логику
Чистое решение — сделать верхний каталог полноценным пакетом и перенести общие части, например Enum с кодами выхода, в отдельный модуль. CLI остаётся тонкой точкой входа, а тесты и CLI импортируют один и тот же общий модуль. Запускайте pytest из корня пакета, чтобы импорты разрешались одинаково.
Перестройте проект так:
SomeDir/
│
├── script.py               # точка входа CLI
├── common.py               # общий код: коды выхода и т. п.
├── __init__.py             # помечает этот каталог как пакет
│
└── TestDir/
    └── test.py             # тесты pytest
Общая логика — в common.py:
# SomeDir/common.py
from enum import Enum
class ExitSignal(Enum):
    OK = 0
    FAIL = 1
Точка входа CLI импортирует из общего модуля и завершает работу с нужным кодом:
# SomeDir/script.py
import sys
from common import ExitSignal
def main():
    # имитируем некоторую обработку
    sys.exit(ExitSignal.OK.value)
if __name__ == '__main__':
    main()
Тесты импортируют те же символы из того же общего модуля и сверяют код возврата подпроцесса:
# SomeDir/TestDir/test.py
import subprocess
from common import ExitSignal
def test_script_success():
    outcome = subprocess.run(['python', '../script.py'], capture_output=True)
    assert outcome.returncode == ExitSignal.OK.value
Запускайте тесты из верхнего каталога, чтобы пакет был обнаруживаемым:
cd SomeDir
pytest
Почему этот подход важен
Такой подход делает импорты предсказуемыми без трогания sys.path, освобождает CLI от переиспользуемой логики и гарантирует, что приложение и тесты используют единый источник правды для кодов выхода. Централизуя общие определения в модуле внутри пакета, вы предотвращаете конфликты имён и избегаете хрупких костылей, зависящих от текущей рабочей директории или контекста запуска.
Практический результат и рекомендации
Организуйте проект как пакет, вынесите общие элементы вроде Enum в отдельный модуль, оставьте script.py точкой входа и запускайте pytest из корня пакета. Это даёт стабильные импорты, чистую границу между кодом CLI и библиотекой и простое переиспользование в тестах без модификации sys.path и без разовых обходных манёвров.