| #!/usr/bin/env python3 | |
| # SPDX-License-Identifier: GPL-2.0 | |
| # Copyright(c) 2025-2026: Mauro Carvalho Chehab <mchehab@kernel.org>. | |
| # | |
| # pylint: disable=C0103,R0912,R0914,E1101 | |
| """ | |
| 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) | |
| #: Dictionary to store organized test results. | |
| self.test_results = {} | |
| #: max length of the test names. | |
| self.max_name_length = 0 | |
| def startTest(self, test): | |
| super().startTest(test) | |
| test_id = test.id() | |
| parts = test_id.split(".") | |
| # Extract module, class, and method names | |
| 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] | |
| # Build the hierarchical structure | |
| 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] = [] | |
| # Track maximum test name length for alignment | |
| 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. | |
| """ | |
| # Check for ANSI color support | |
| use_color = sys.stdout.isatty() | |
| COLORS = { | |
| "OK": "\033[32m", # Green | |
| "FAIL": "\033[31m", # Red | |
| "SKIP": "\033[1;33m", # Yellow | |
| "PARTIAL": "\033[33m", # Orange | |
| "EXPECTED_FAIL": "\033[36m", # Cyan | |
| "reset": "\033[0m", # Reset to default terminal color | |
| } | |
| if not use_color: | |
| for c in COLORS: | |
| COLORS[c] = "" | |
| # Calculate maximum test name length | |
| 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) # +1 for colon | |
| max_length = max(lengths) + 2 # Additional padding | |
| except ValueError: | |
| sys.exit("Test list is empty") | |
| # Print results | |
| 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 | |
| # Get base status without reason for SKIP | |
| 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 summary | |
| 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() | |
| # ensure it gets stopped after | |
| atexit.register(patcher.stop) | |
| if verbose >= 2: | |
| unittest.TextTestRunner(verbosity=verbose).run = lambda suite: suite | |
| # Load ONLY tests from the calling file | |
| 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)) | |
| # Flatten the suite for environment injection | |
| tests_to_inject = flatten_suite(suite) | |
| # Filter tests by method name if -k specified | |
| if args.keyword: | |
| try: | |
| pattern = re.compile(args.keyword) | |
| filtered_suite = unittest.TestSuite() | |
| for test in tests_to_inject: # Use the pre-flattened list | |
| 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: | |
| # Maintain original suite structure if no keyword filtering | |
| 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) |