If you are using pytest, you can first monkeypatch the user input
and the print
-ed output, then import the example module (or whichever module you need to test), then check that the print
-ed output matches the expected.
Given the example module (example.py) and a test file all in a directory named files:
$ tree files
files
├── example.py
└── test_example.py
The test function could look like this:
import builtins
import importlib
import io
import sys
import pytest
from pytest import MonkeyPatch
def test_example_123(monkeypatch: MonkeyPatch):
mocked_input = lambda prompt="": "123"
mocked_stdout = io.StringIO()
with monkeypatch.context() as m:
m.setattr(builtins, "input", mocked_input)
m.setattr(sys, "stdout", mocked_stdout)
sys.modules.pop("example", None)
importlib.import_module(name="example", package="files")
assert mocked_stdout.getvalue().strip() == "123"
$ python3.9 -m pytest --no-header -vv files/test_example.py
...
collected 1 item
files/test_example.py::test_example_123 PASSED
The monkeypatch
fixture can replace the attributes of objects and modules, to something that doesn't depend on any external inputs or on the environment.
In this case, the test patches 2 things:
The builtins.input
function, such that it gets the input string from the test instead of stopping to get user input. The mocked user input is saved in mocked_input
. If the module calls input
multiple times, then you can change this from a lambda function to a regular function, that returns different strings based on how many times it was called or base it on the prompt
.
# example.py
x = input("x")
y = input("y")
z = input("z")
if int(x):
print(x)
# Hacky way of relying on mutable default arguments
# to keep track of how many times mocked_input was called
def mocked_input(prompt="", return_vals=["3333", "2222", "1111"]):
return return_vals.pop(-1)
# Return a fake value based on the prompt
def mocked_input(prompt=""):
return {"x": "1111", "y": "2222", "z": "3333"}[prompt]
The sys.stdout
function, which is the default location where the builtins.print
function will print out objects passed to it. The print outs are captured and stored in mocked_stdout
, which is a io.StringIO
instance compatible with print
. As explained in the docs, multiple print
's would result in still 1 string, but separated by \n
, ex. 'print1\nprint2\nprint3'
. You can just split and use as a list.
The last piece of the puzzle is using importlib
to import that example module (what you call the "single python file" example.py) during runtime and only when the test is actually run.
The code
importlib.import_module(name="example", package="files")
is similar to doing
from files import example
The problem with your "single python file" is that, since none of the code is wrapped in functions, all the code will be immediately run the moment that module is imported. Furthermore, Python caches imported modules, so the patched input
and print
would only take effect when the module is first imported. This is a problem when you need to re-run the module multiple times for multiple tests.
As a workaround, you can pop
-off the cached "examples"
module from sys.modules
before importing
sys.modules.pop("example", None)
When the module is successfully imported, mocked_stdout
should now have whatever is supposed to be print
-ed out to sys.stdout
. You can then just do a simple assertion check.
To test multiple input and output combinations, use pytest.mark.parametrize
to pass-in different test_input
and expected_output
, replacing the hardcoded "123"
from the previous code.
@pytest.mark.parametrize(
"test_input, expected_output",
[
("456", "456"),
("-999", "-999"),
("0", ""), # Expect int("0") to be 0, so it is False-y
],
)
def test_example(monkeypatch: MonkeyPatch, test_input: str, expected_output: str):
mocked_input = lambda prompt="": test_input
mocked_stdout = io.StringIO()
with monkeypatch.context() as m:
m.setattr(builtins, "input", mocked_input)
m.setattr(sys, "stdout", mocked_stdout)
sys.modules.pop("example", None)
importlib.import_module(name="example", package="files")
assert mocked_stdout.getvalue().strip() == expected_output
$ python3.9 -m pytest --no-header -vv files/test_example.py
...
collected 4 items
files/test_example.py::test_example_123 PASSED
files/test_example.py::test_example[456-456] PASSED
files/test_example.py::test_example[-999--999] PASSED
files/test_example.py::test_example[0-] PASSED
Lastly, for cases like "4.56"
where you expect an ValueError
, it's more useful to just test that an Exception was raised rather than checking the print
-ed output.
@pytest.mark.parametrize(
"test_input",
[
"9.99",
"not an int",
],
)
def test_example_errors(monkeypatch: MonkeyPatch, test_input: str):
mocked_input = lambda prompt="": test_input
with monkeypatch.context() as m:
m.setattr(builtins, "input", mocked_input)
sys.modules.pop("example", None)
with pytest.raises(ValueError) as exc:
importlib.import_module(name="example", package="files")
assert str(exc.value) == f"invalid literal for int() with base 10: '{test_input}'"
$ python3.9 -m pytest --no-header -vv files/test_example.py
...
collected 6 items
files/test_example.py::test_example_123 PASSED
files/test_example.py::test_example[123-123] PASSED
files/test_example.py::test_example[-999--999] PASSED
files/test_example.py::test_example[0-] PASSED
files/test_example.py::test_example_errors[9.99] PASSED
files/test_example.py::test_example_errors[not an int] PASSED