edq.testing.run
Discover and run unit tests (via Python's unittest package) that live in this project's base package (the parent of this package).
1""" 2Discover and run unit tests (via Python's unittest package) 3that live in this project's base package 4(the parent of this package). 5""" 6 7import argparse 8import os 9import re 10import sys 11import typing 12import unittest 13 14DEFAULT_TEST_FILENAME_PATTERN: str = '*_test.py' 15""" The default pattern for test files. """ 16 17CLEANUP_FUNC_NAME: str = 'suite_cleanup' 18""" 19If a test class has a function with this name, 20then the function will be run after the test suite finishes. 21""" 22 23def _collect_tests(suite: typing.Union[unittest.TestCase, unittest.suite.TestSuite]) -> typing.List[unittest.TestCase]: 24 """ 25 Collect and return tests (unittest.TestCase) from the target directory. 26 """ 27 28 if (isinstance(suite, unittest.TestCase)): 29 return [suite] 30 31 if (not isinstance(suite, unittest.suite.TestSuite)): 32 raise ValueError(f"Unknown test type: '{str(type(suite))}'.") 33 34 test_cases = [] 35 for test_object in suite: 36 test_cases += _collect_tests(test_object) 37 38 return test_cases 39 40def run(args: typing.Union[argparse.Namespace, typing.Dict[str, typing.Any], None] = None) -> int: 41 """ 42 Discover and run unit tests. 43 This function may change your working directory. 44 Will raise if tests fail to load (e.g. syntax errors) and a suggested exit code otherwise. 45 """ 46 47 if (args is None): 48 args = {} 49 50 if (not isinstance(args, dict)): 51 args = vars(args) 52 53 if (args.get('work_dir', None) is not None): 54 os.chdir(args['work_dir']) 55 56 if (args.get('path_additions', None) is not None): 57 for path in args['path_additions']: 58 sys.path.append(path) 59 60 test_dirs = args.get('test_dirs', None) 61 if (test_dirs is None): 62 test_dirs = [] 63 64 if (len(test_dirs) == 0): 65 test_dirs.append('.') 66 67 runner = unittest.TextTestRunner(verbosity = 3) 68 test_cases = [] 69 70 for test_dir in test_dirs: 71 discovered_suite = unittest.TestLoader().discover(test_dir, pattern = args.get('filename_pattern', DEFAULT_TEST_FILENAME_PATTERN)) 72 test_cases += _collect_tests(discovered_suite) 73 74 # Cleanup class functions from test classes. 75 # {class: function, ...} 76 cleanup_funcs = {} 77 78 tests = unittest.suite.TestSuite() 79 80 for test_case in test_cases: 81 if (isinstance(test_case, unittest.loader._FailedTest)): # type: ignore[attr-defined] 82 raise ValueError(f"Failed to load test: '{test_case.id()}'.") from test_case._exception 83 84 pattern = args.get('pattern', None) 85 if ((pattern is None) or re.search(pattern, test_case.id())): 86 tests.addTest(test_case) 87 88 # Check for a cleanup function. 89 if (hasattr(test_case.__class__, CLEANUP_FUNC_NAME)): 90 cleanup_funcs[test_case.__class__] = getattr(test_case.__class__, CLEANUP_FUNC_NAME) 91 else: 92 print(f"Skipping {test_case.id()} because of match pattern.") 93 94 result = runner.run(tests) 95 faults = len(result.errors) + len(result.failures) 96 97 # Perform any cleanup. 98 for cleanup_func in cleanup_funcs.values(): 99 cleanup_func() 100 101 if (not result.wasSuccessful()): 102 # This value will be used as an exit status, so it should not be larger than a byte. 103 # (Some higher values are used specially, so just keep it at a round number.) 104 return max(1, min(faults, 100)) 105 106 return 0 107 108def main() -> int: 109 """ Parse the CLI arguments and run tests. """ 110 111 args = _get_parser().parse_args() 112 return run(args) 113 114def _get_parser() -> argparse.ArgumentParser: 115 """ Build a parser for CLI arguments. """ 116 117 parser = argparse.ArgumentParser(description = 'Run unit tests discovered in this project.') 118 119 parser.add_argument('--work-dir', dest = 'work_dir', 120 action = 'store', type = str, default = os.getcwd(), 121 help = 'Set the working directory when running tests, defaults to the current working directory (%(default)s).') 122 123 parser.add_argument('--tests-dir', dest = 'test_dirs', 124 action = 'append', 125 help = 'Discover tests from these directories. Defaults to the current directory.') 126 127 parser.add_argument('--add-path', dest = 'path_additions', 128 action = 'append', 129 help = 'If supplied, add this path the sys.path before running tests.') 130 131 parser.add_argument('--filename-pattern', dest = 'filename_pattern', 132 action = 'store', type = str, default = DEFAULT_TEST_FILENAME_PATTERN, 133 help = 'The pattern to use to find test files (default: %(default)s).') 134 135 parser.add_argument('pattern', 136 action = 'store', type = str, default = None, nargs = '?', 137 help = 'If supplied, only tests with names matching this pattern will be run. This pattern is used directly in re.search().') 138 139 return parser 140 141if __name__ == '__main__': 142 sys.exit(main())
DEFAULT_TEST_FILENAME_PATTERN: str =
'*_test.py'
The default pattern for test files.
CLEANUP_FUNC_NAME: str =
'suite_cleanup'
If a test class has a function with this name, then the function will be run after the test suite finishes.
def
run(args: Union[argparse.Namespace, Dict[str, Any], NoneType] = None) -> int:
41def run(args: typing.Union[argparse.Namespace, typing.Dict[str, typing.Any], None] = None) -> int: 42 """ 43 Discover and run unit tests. 44 This function may change your working directory. 45 Will raise if tests fail to load (e.g. syntax errors) and a suggested exit code otherwise. 46 """ 47 48 if (args is None): 49 args = {} 50 51 if (not isinstance(args, dict)): 52 args = vars(args) 53 54 if (args.get('work_dir', None) is not None): 55 os.chdir(args['work_dir']) 56 57 if (args.get('path_additions', None) is not None): 58 for path in args['path_additions']: 59 sys.path.append(path) 60 61 test_dirs = args.get('test_dirs', None) 62 if (test_dirs is None): 63 test_dirs = [] 64 65 if (len(test_dirs) == 0): 66 test_dirs.append('.') 67 68 runner = unittest.TextTestRunner(verbosity = 3) 69 test_cases = [] 70 71 for test_dir in test_dirs: 72 discovered_suite = unittest.TestLoader().discover(test_dir, pattern = args.get('filename_pattern', DEFAULT_TEST_FILENAME_PATTERN)) 73 test_cases += _collect_tests(discovered_suite) 74 75 # Cleanup class functions from test classes. 76 # {class: function, ...} 77 cleanup_funcs = {} 78 79 tests = unittest.suite.TestSuite() 80 81 for test_case in test_cases: 82 if (isinstance(test_case, unittest.loader._FailedTest)): # type: ignore[attr-defined] 83 raise ValueError(f"Failed to load test: '{test_case.id()}'.") from test_case._exception 84 85 pattern = args.get('pattern', None) 86 if ((pattern is None) or re.search(pattern, test_case.id())): 87 tests.addTest(test_case) 88 89 # Check for a cleanup function. 90 if (hasattr(test_case.__class__, CLEANUP_FUNC_NAME)): 91 cleanup_funcs[test_case.__class__] = getattr(test_case.__class__, CLEANUP_FUNC_NAME) 92 else: 93 print(f"Skipping {test_case.id()} because of match pattern.") 94 95 result = runner.run(tests) 96 faults = len(result.errors) + len(result.failures) 97 98 # Perform any cleanup. 99 for cleanup_func in cleanup_funcs.values(): 100 cleanup_func() 101 102 if (not result.wasSuccessful()): 103 # This value will be used as an exit status, so it should not be larger than a byte. 104 # (Some higher values are used specially, so just keep it at a round number.) 105 return max(1, min(faults, 100)) 106 107 return 0
Discover and run unit tests. This function may change your working directory. Will raise if tests fail to load (e.g. syntax errors) and a suggested exit code otherwise.
def
main() -> int:
109def main() -> int: 110 """ Parse the CLI arguments and run tests. """ 111 112 args = _get_parser().parse_args() 113 return run(args)
Parse the CLI arguments and run tests.