"""
Provides helper functions and classes execute python unit tests.
Those help functions provide a nice colored output summary of each
executed test and, when a test fails, it shows the different in diff
format when running in verbose mode, like::
$ tools/unittests/nested_match.py -v
...
Traceback (most recent call last):
File "/new_devel/docs/tools/unittests/nested_match.py", line 69, in test_count_limit
self.assertEqual(replaced, "bar(a); bar(b); foo(c)")
~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: 'bar(a) foo(b); foo(c)' != 'bar(a); bar(b); foo(c)'
- bar(a) foo(b); foo(c)
? ^^^^
+ bar(a); bar(b); foo(c)
? ^^^^^
...
It also allows filtering what tests will be executed via ``-k`` parameter.
Typical usage is to do::
from unittest_helper import run_unittest
...
if __name__ == "__main__":
run_unittest(__file__)
If passing arguments is needed, on a more complex scenario, it can be
used like on this example::
from unittest_helper import TestUnits, run_unittest
...
env = {'sudo': ""}
...
if __name__ == "__main__":
runner = TestUnits()
base_parser = runner.parse_args()
base_parser.add_argument('--sudo', action='store_true',
help='Enable tests requiring sudo privileges')
args = base_parser.parse_args()
# Update module-level flag
if args.sudo:
env['sudo'] = "1"
# Run tests with customized arguments
runner.run(__file__, parser=base_parser, args=args, env=env)
"""
import argparse
import atexit
import os
import re
import unittest
import sys
from unittest.mock import patch
class Summary(unittest.TestResult):
"""
Overrides ``unittest.TestResult`` class to provide a nice colored
summary. When in verbose mode, displays actual/expected difference in
unified diff format.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.test_results = {}
self.max_name_length = 0
def startTest(self, test):
super().startTest(test)
test_id = test.id()
parts = test_id.split(".")
if len(parts) >= 3:
module_name = parts[-3]
else:
module_name = ""
if len(parts) >= 2:
class_name = parts[-2]
else:
class_name = ""
method_name = parts[-1]
if module_name not in self.test_results:
self.test_results[module_name] = {}
if class_name not in self.test_results[module_name]:
self.test_results[module_name][class_name] = []
display_name = f"{method_name}:"
self.max_name_length = max(len(display_name), self.max_name_length)
def _record_test(self, test, status):
test_id = test.id()
parts = test_id.split(".")
if len(parts) >= 3:
module_name = parts[-3]
else:
module_name = ""
if len(parts) >= 2:
class_name = parts[-2]
else:
class_name = ""
method_name = parts[-1]
self.test_results[module_name][class_name].append((method_name, status))
def addSuccess(self, test):
super().addSuccess(test)
self._record_test(test, "OK")
def addFailure(self, test, err):
super().addFailure(test, err)
self._record_test(test, "FAIL")
def addError(self, test, err):
super().addError(test, err)
self._record_test(test, "ERROR")
def addSkip(self, test, reason):
super().addSkip(test, reason)
self._record_test(test, f"SKIP ({reason})")
def printResults(self, verbose):
"""
Print results using colors if tty.
"""
use_color = sys.stdout.isatty()
COLORS = {
"OK": "\033[32m",
"FAIL": "\033[31m",
"SKIP": "\033[1;33m",
"PARTIAL": "\033[33m",
"EXPECTED_FAIL": "\033[36m",
"reset": "\033[0m",
}
if not use_color:
for c in COLORS:
COLORS[c] = ""
if not self.test_results:
return
try:
lengths = []
for module in self.test_results.values():
for tests in module.values():
for test_name, _ in tests:
lengths.append(len(test_name) + 1)
max_length = max(lengths) + 2
except ValueError:
sys.exit("Test list is empty")
for module_name, classes in self.test_results.items():
if verbose:
print(f"{module_name}:")
for class_name, tests in classes.items():
if verbose:
print(f" {class_name}:")
for test_name, status in tests:
if not verbose and status in [ "OK", "EXPECTED_FAIL" ]:
continue
if status.startswith("SKIP"):
status_code = status.split()[0]
else:
status_code = status
color = COLORS.get(status_code, "")
print(
f" {test_name + ':':<{max_length}}{color}{status}{COLORS['reset']}"
)
if verbose:
print()
print(f"\nRan {self.testsRun} tests", end="")
if hasattr(self, "timeTaken"):
print(f" in {self.timeTaken:.3f}s", end="")
print()
if not self.wasSuccessful():
print(f"\n{COLORS['FAIL']}FAILED (", end="")
failures = getattr(self, "failures", [])
errors = getattr(self, "errors", [])
if failures:
print(f"failures={len(failures)}", end="")
if errors:
if failures:
print(", ", end="")
print(f"errors={len(errors)}", end="")
print(f"){COLORS['reset']}")
def flatten_suite(suite):
"""Flatten test suite hierarchy."""
tests = []
for item in suite:
if isinstance(item, unittest.TestSuite):
tests.extend(flatten_suite(item))
else:
tests.append(item)
return tests
class TestUnits:
"""
Helper class to set verbosity level.
This class discover test files, import its unittest classes and
executes the test on it.
"""
def parse_args(self):
"""Returns a parser for command line arguments."""
parser = argparse.ArgumentParser(description="Test runner with regex filtering")
parser.add_argument("-v", "--verbose", action="count", default=1)
parser.add_argument("-q", "--quiet", action="store_true")
parser.add_argument("-f", "--failfast", action="store_true")
parser.add_argument("-k", "--keyword",
help="Regex pattern to filter test methods")
return parser
def run(self, caller_file=None, pattern=None,
suite=None, parser=None, args=None, env=None):
"""
Execute all tests from the unity test file.
It contains several optional parameters:
``caller_file``:
- name of the file that contains test.
typical usage is to place __file__ at the caller test, e.g.::
if __name__ == "__main__":
TestUnits().run(__file__)
``pattern``:
- optional pattern to match multiple file names. Defaults
to basename of ``caller_file``.
``suite``:
- an unittest suite initialized by the caller using
``unittest.TestLoader().discover()``.
``parser``:
- an argparse parser. If not defined, this helper will create
one.
``args``:
- an ``argparse.Namespace`` data filled by the caller.
``env``:
- environment variables that will be passed to the test suite
At least ``caller_file`` or ``suite`` must be used, otherwise a
``TypeError`` will be raised.
"""
if not args:
if not parser:
parser = self.parse_args()
args = parser.parse_args()
if not caller_file and not suite:
raise TypeError("Either caller_file or suite is needed at TestUnits")
if args.quiet:
verbose = 0
else:
verbose = args.verbose
if not env:
env = os.environ.copy()
env["VERBOSE"] = f"{verbose}"
patcher = patch.dict(os.environ, env)
patcher.start()
atexit.register(patcher.stop)
if verbose >= 2:
unittest.TextTestRunner(verbosity=verbose).run = lambda suite: suite
if not suite:
if not pattern:
pattern = caller_file
loader = unittest.TestLoader()
suite = loader.discover(start_dir=os.path.dirname(caller_file),
pattern=os.path.basename(caller_file))
tests_to_inject = flatten_suite(suite)
if args.keyword:
try:
pattern = re.compile(args.keyword)
filtered_suite = unittest.TestSuite()
for test in tests_to_inject:
method_name = test.id().split(".")[-1]
if pattern.search(method_name):
filtered_suite.addTest(test)
suite = filtered_suite
except re.error as e:
sys.stderr.write(f"Invalid regex pattern: {e}\n")
sys.exit(1)
else:
suite = unittest.TestSuite(tests_to_inject)
if verbose >= 2:
resultclass = None
else:
resultclass = Summary
runner = unittest.TextTestRunner(verbosity=args.verbose,
resultclass=resultclass,
failfast=args.failfast)
result = runner.run(suite)
if resultclass:
result.printResults(verbose)
sys.exit(not result.wasSuccessful())
def run_unittest(fname):
"""
Basic usage of TestUnits class.
Use it when there's no need to pass any extra argument to the tests
with. The recommended way is to place this at the end of each
unittest module::
if __name__ == "__main__":
run_unittest(__file__)
"""
TestUnits().run(fname)