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 и без разовых обходных манёвров.

Статья основана на вопросе с StackOverflow от Oersted и ответе John Eipe.