Skip to content

Instantly share code, notes, and snippets.

@Davoodeh
Created August 14, 2021 18:28
Show Gist options
  • Save Davoodeh/6db08ef8a1e96c0d2a7fc701c4d18fe7 to your computer and use it in GitHub Desktop.
Save Davoodeh/6db08ef8a1e96c0d2a7fc701c4d18fe7 to your computer and use it in GitHub Desktop.
A Python test script for testing simple input based scripts using Pytest
#!/bin/sh
# Generates a python script that checks another input-based python script
# Uses another python file as the reference for "true" answers ($header file)
#
# $1: path to script to test
# $2: path to the file holding answers variable
# $3: optional pytest flags
# $4: if "keep", the script doesn't remove the constructed file
#
# A `header.py` example:
# ```
# # Pairs to check a program that takes 4 inputs and prints the minimum
# VALUES_RESULTS_PAIRS = [
# ([43, 123, -4, 23], iof(-4)), # negative
# ([1, 2, 4, 1], iof(1)), # multiple
# ([1, 2, 4, 5], iof(1)), # First number
# ([2, 1, 4, 5], iof(1)), # Second number
# ([2, 2, 1, 5], iof(1)), # Third number
# ([3, 1, 42, 0], iof(0)), # Forth number
# ]
# ```
file="/tmp/test-script.py"
header="${2-:$1}"
cat >"$file" <<EOF
#!/usr/bin/env python3
# requires python-console-scripts (script_runner)
# it gets included in other scripts with these two variables modified
# SCRIPT_NAME: str
# VALUES_RESULTS_PAIRS: list[tuple[list[str], str|list|tuple]]
import pytest
from collections import deque
# Helper functions
def iof(x) -> tuple[int, float]:
"""Return int and float of the same number x."""
return (int(x), float(x))
SCRIPT_NAME: str = "$1"
$(cat "$header")
def make_multiple_inputs(inputs):
"""Make values to patch on the __builtins__.input.
this could be used in a monkeypatch like
m.setitem(__builtins__, "input", make_multiple_inputs(values))
where values is a sequence of inputs that is expected for a "user" to
put in the input prompt
"""
if not isinstance(inputs, (list, tuple)):
inputs = [inputs]
inputs = [str(i) for i in inputs]
inputs = deque(inputs)
return lambda _: inputs.popleft()
@pytest.mark.parametrize("values, results", VALUES_RESULTS_PAIRS)
def test_script(values, results, monkeypatch, script_runner):
"""Test the script with the included VALUES_RESULTS_PAIRS."""
with monkeypatch.context() as m:
m.setitem(__builtins__, "input", make_multiple_inputs(values))
ret = script_runner.run(SCRIPT_NAME)
assert ret.success
if not isinstance(results, (list, tuple)):
value = ret.stdout == str(results) + "\n"
else: # if a collection of results
value = ret.stdout == str(results[0]) + "\n"
for i in results[1:]:
if value: # if it's already True, break
break
value = value or (ret.stdout == str(i) + "\n")
if not value:
pytest.fail(
f"{ret.stdout[:-1]} didn't match any of results: {results}",
)
EOF
pytest $3 "$file"
x=$?
[ "$4" = "keep" ] && rm "$file"
exit $x
#!/usr/bin/env python3
"""Make a dynamic pytest script for input based scripts and run it.
A rewrite of my test-py-script, originally written in shell.
Requires pytest, python 3.9 and python-console-scripts plugin for pytest
(`script_runner` fixture)
Takes 2 inputs (from shell or built-in input function):
- $1: Path to the script that you want to test (`script_path`)
- $2: Path to the answer file (`header_path`)
Optional there can be a third and forth argument for the program, only passable
through shell.
- $3: Pytest space-separated flags (e.g "-s")
- $4: If equal to "keep", prevents the deletion of the constructed test script
from the header files.
The header file is another python script having a mandatory
`VALUES_RESULTS_PAIRS: list[tuple[list[str], str|list|tuple]]`.
BUG I couldn't be bothered to implement `SCRIPT_NAME` variable support
like the shell version. Contributions are welcome.
NOTE Ironically, I never tested, nor ran the script for I basically have no use
for it since I just use the shell version. Use at your own risk.
There are "helper functions" like "iof" in this file that will implecitely get
injected in the final cloned/constructed test file. It means one can safely
ignore their lint's errors about "iof" not found and use it safely.
Examples (assuming the script name is `script.py`):
- script.py # providing sufficient permissions are granted
- python script.py
- python script.py /path/to/script-to-be-tested.py
- python script.py /path/to/script-to-be-tested.py /path/to/answers.py
- python script.py /path/to/script-to-be-tested.py /path/to/answers.py "-s"
- python script.py /path/to/script-to-be-tested.py /path/to/answers.py -s keep
A `header.py` example:
```
# Pairs to check a program that takes 4 inputs and prints the minimum
VALUES_RESULTS_PAIRS = [
([43, 123, -4, 23], iof(-4)), # negative
([1, 2, 4, 1], iof(1)), # multiple
([1, 2, 4, 5], iof(1)), # First number
([2, 1, 4, 5], iof(1)), # Second number
([2, 2, 1, 5], iof(1)), # Third number
([3, 1, 42, 0], iof(0)), # Forth number
]
```
"""
import inspect
import os
import sys
import tempfile
from collections import deque
from shutil import rmtree
import pytest
# Helper functions
def iof(x) -> tuple[int, float]:
"""Return int and float of the same number x."""
return (int(x), float(x))
def make_multiple_inputs(inputs):
"""Make values to patch on the `__builtins__.input`.
this could be used in a monkeypatch like
`m.setitem(__builtins__, "input", make_multiple_inputs(values))`
where values is a sequence of inputs that is expected for a "user" to
put in the input prompt
"""
if not isinstance(inputs, (list, tuple)):
inputs = [inputs]
inputs = [str(i) for i in inputs]
inputs = deque(inputs)
return lambda _: inputs.popleft()
def the_actual_test_script(
script_path: str,
values,
results,
monkeypatch,
script_runner,
) -> None:
"""Test the script with the included `VALUES_RESULTS_PAIRS`."""
with monkeypatch.context() as m:
m.setitem(__builtins__, "input", make_multiple_inputs(values))
ret = script_runner.run(script_path)
assert ret.success
if not isinstance(results, (list, tuple)):
value = ret.stdout == str(results) + "\n"
else: # if a collection of results
value = ret.stdout == str(results[0]) + "\n"
for i in results[1:]:
if value: # if it's already True, break
break
value = value or (ret.stdout == str(i) + "\n")
if not value:
pytest.fail(
f"{ret.stdout[:-1]} didn't match any of results: {results}",
)
# List helper functions this far
HELPER_FUNCTIONS: list[str] = [
name
for name, func in inspect.getmembers(sys.modules[__name__])
if inspect.isfunction(func) and func.__module__ == __name__
# and not name.startswith("invisible_")
]
# Don't run the block if the file is getting included (prevents recursion)
if __name__ == "__main__":
# NOTE invisible_* functions won't be included in generated tests
def argv_or_input(index, prompt) -> str:
"""Take an input from argv[index], if undefined, ask interactively."""
try:
value = sys.argv[index]
except IndexError:
print("Insufficient inputs")
value = input(prompt)
return value
# Variables
script_path: str = os.path.expanduser(
argv_or_input(1, "Input the path of the script you want to test: ")
)
header_path: str = os.path.expanduser(
argv_or_input(2, "Input the path of the header (answers) file: ")
)
file_split_path: tuple[str, str] = os.path.split(__file__)
header_split_path: tuple[str, str] = os.path.split(header_path)
parent_module_name: str = os.path.splitext(
file_split_path[1],
)[0]
tmp_dir = tempfile.mkdtemp()
clone_path: str = os.path.join(tmp_dir, header_split_path[1])
clone_data: tuple[str, str] = os.path.split(clone_path)
print("Turn the header in a full blown test")
# Make a clone of the header file, inject it with the functions, add some
# more pytest related ones and run the test
# Clone is the newly generated test file from the header
with open(header_path, "r") as file:
with open(clone_path, "w") as clone:
clone.write(
"from importlib import import_module\n"
"import pytest, os, sys\n"
f'sys.path.append("{file_split_path[0]}")\n\n'
)
for i in HELPER_FUNCTIONS:
clone.write(f"from {parent_module_name} import {i}\n")
clone.write("\n\n")
clone.write(file.read()) # The original header contents
clone.write(
f"""@pytest.mark.parametrize("values, results", VALUES_RESULTS_PAIRS)
def test_script(
values,
results,
monkeypatch,
script_runner,
):
the_actual_test_script(
"{script_path}",
values,
results,
monkeypatch,
script_runner,
)
"""
)
print(f"Created the test {clone_path}\n\n")
# Manage the final args and run the test
args = [clone_path]
keep_the_tmp_dir = False
if len(sys.argv) >= 4:
args = [*sys.argv[3].split(), *args]
if len(sys.argv) == 5 and sys.argv[5] == "keep":
keep_the_tmp_dir = True
pytest_status = pytest.main(args)
if not keep_the_tmp_dir:
rmtree(tmp_dir) # Delete the temp files after successful load
exit(pytest_status)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment