2025, Oct 20 10:00
Reliable Python CLI Testing with pytest: Solve ModuleNotFoundError by Packaging Your Project
Learn how to avoid ModuleNotFoundError in pytest when testing Python CLIs. Package your project, move shared code to a module, and run subprocess tests easily.
Reliable CLI testing in Python often hits a wall when a test needs to import symbols from a top-level script while also invoking that script via subprocess. A typical manifestation is a ModuleNotFoundError in pytest when trying to import from the script’s module path. The issue is not with pytest itself, but with how the project is structured and how Python resolves imports.
Minimal setup that reproduces the problem
Consider this layout:
SomeDir
|- script.py
|- TestDir
|  |- test.py
The script exposes exit codes via an Enum and terminates with sys.exit. Tests run the script as a subprocess and validate returncode against the Enum. Importing the Enum directly from the script inside the test fails because the top-level directory is not importable as a package.
Below is a condensed version that demonstrates the failure mode:
# SomeDir/script.py
from enum import Enum
import sys
class ExitSignal(Enum):
    OK = 0
    FAIL = 1
class Runner:
    def execute(self):
        # simulate some processing
        sys.exit(ExitSignal.OK.value)
if __name__ == '__main__':
    Runner().execute()
# SomeDir/TestDir/test.py
import subprocess
from script import ExitSignal  # ModuleNotFoundError when run from TestDir
def test_cli_exit_status():
    completed = subprocess.run(['python', '../script.py'], capture_output=True)
    assert completed.returncode == ExitSignal.OK.value
Why the import fails
When pytest is executed from inside the tests directory, Python does not treat the parent directory as a package. The interpreter cannot resolve from script import ExitSignal because the parent folder is not on sys.path as an importable package, and it lacks the packaging signal that allows absolute or package-relative imports. Quick fixes like mutating sys.path may seem to work, but they are fragile and easy to get wrong.
The robust approach: turn the directory into a package and extract reusable logic
The clean solution is to convert the top-level directory into a proper package and move the shared pieces, such as the Enum with exit codes, into a dedicated module. The CLI remains a thin entry point, while tests and the CLI both import the same shared module. Run pytest from the package root so imports resolve consistently.
Restructure the project like this:
SomeDir/
│
├── script.py               # CLI entry point
├── common.py               # shared code: exit codes, etc.
├── __init__.py             # marks this directory as a package
│
└── TestDir/
    └── test.py             # pytest tests
Shared logic goes into common.py:
# SomeDir/common.py
from enum import Enum
class ExitSignal(Enum):
    OK = 0
    FAIL = 1
The CLI entry point imports from the shared module and exits with the appropriate code:
# SomeDir/script.py
import sys
from common import ExitSignal
def main():
    # simulate some processing
    sys.exit(ExitSignal.OK.value)
if __name__ == '__main__':
    main()
Tests import the same symbols from the same shared module and validate the subprocess exit status:
# 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
Execute tests from the top-level directory so that the package is discoverable:
cd SomeDir
pytest
Why this pattern matters
This approach makes imports predictable without touching sys.path, keeps the CLI free from reusable logic, and ensures that both the application and tests consume a single source of truth for exit codes. By centralizing shared definitions in a module that belongs to the package, you prevent name collisions and avoid brittle hacks that depend on the current working directory or execution context.
Practical outcome and guidance
Organize the project as a package, push common pieces like Enums into a dedicated module, leave script.py as the entry point, and run pytest from the package root. This produces stable imports, clean boundaries between CLI and library code, and straightforward reuse in tests without modifying sys.path or relying on ad hoc workarounds.