How to test a single python file having no functions in it? How to test for multiple inputs and outputs?

Question:

I want to test a Python file but the file doesn’t contain any functions and hence returns nothing.

example.py

x = input()
if int(x):
    print(x)

I don’t want to make a function like this:

def check_x():
    x = input()
    if int(x):
        print(x) or return x

or

def check_x(x):
    if int(x):
        print(x) or return x

How to test a file containing an input call, or multiple input and print statements having no functions that return a value?

I use pytest for testing.

Asked By: shraysalvi

||

Answers:

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. 'print1nprint2nprint3'. 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
Answered By: Gino Mempin
Categories: questions Tags: ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.