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, failfast = args.get('fail_fast', False)) 68 test_cases = [] 69 70 for test_dir in test_dirs: 71 discovered_suite = unittest.TestLoader().discover(test_dir, 72 pattern = args.get('filename_pattern', DEFAULT_TEST_FILENAME_PATTERN), 73 top_level_dir = args.get('discover_top_level_dir', None)) 74 test_cases += _collect_tests(discovered_suite) 75 76 # Cleanup class functions from test classes. 77 # {class: function, ...} 78 cleanup_funcs = {} 79 80 tests = unittest.suite.TestSuite() 81 82 for test_case in test_cases: 83 if (isinstance(test_case, unittest.loader._FailedTest)): # type: ignore[attr-defined] 84 raise ValueError(f"Failed to load test: '{test_case.id()}'.") from test_case._exception 85 86 pattern = args.get('pattern', None) 87 if ((pattern is None) or re.search(pattern, test_case.id())): 88 tests.addTest(test_case) 89 90 # Check for a cleanup function. 91 if (hasattr(test_case.__class__, CLEANUP_FUNC_NAME)): 92 cleanup_funcs[test_case.__class__] = getattr(test_case.__class__, CLEANUP_FUNC_NAME) 93 else: 94 print(f"Skipping {test_case.id()} because of match pattern.") 95 96 result = runner.run(tests) 97 faults = len(result.errors) + len(result.failures) 98 99 # Perform any cleanup. 100 for cleanup_func in cleanup_funcs.values(): 101 cleanup_func() 102 103 # Cleanup the system path. 104 if (args.get('path_additions', None) is not None): 105 for path in args['path_additions']: 106 sys.path.pop() 107 108 if (not result.wasSuccessful()): 109 # This value will be used as an exit status, so it should not be larger than a byte. 110 # (Some higher values are used specially, so just keep it at a round number.) 111 return max(1, min(faults, 100)) 112 113 return 0 114 115def main() -> int: 116 """ Parse the CLI arguments and run tests. """ 117 118 args = _get_parser().parse_args() 119 return run(args) 120 121def _get_parser() -> argparse.ArgumentParser: 122 """ Build a parser for CLI arguments. """ 123 124 parser = argparse.ArgumentParser(description = 'Run unit tests discovered in this project.') 125 126 parser.add_argument('pattern', 127 action = 'store', type = str, default = None, nargs = '?', 128 help = 'If supplied, only tests with names matching this pattern will be run. This pattern is used directly in re.search().') 129 130 group = parser.add_argument_group('test runner options') 131 132 group.add_argument('--add-path', dest = 'path_additions', 133 action = 'append', 134 help = 'If supplied, add this path the sys.path before running tests.') 135 136 group.add_argument('--filename-pattern', dest = 'filename_pattern', 137 action = 'store', type = str, default = DEFAULT_TEST_FILENAME_PATTERN, 138 help = 'The pattern to use to find test files (default: %(default)s).') 139 140 group.add_argument('--tests-dir', dest = 'test_dirs', 141 action = 'append', 142 help = 'Discover tests from these directories. Defaults to the current directory.') 143 144 group.add_argument('--work-dir', dest = 'work_dir', 145 action = 'store', type = str, default = os.getcwd(), 146 help = 'Set the working directory when running tests, defaults to the current working directory (%(default)s).') 147 148 return parser 149 150if __name__ == '__main__': 151 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, failfast = args.get('fail_fast', False)) 69 test_cases = [] 70 71 for test_dir in test_dirs: 72 discovered_suite = unittest.TestLoader().discover(test_dir, 73 pattern = args.get('filename_pattern', DEFAULT_TEST_FILENAME_PATTERN), 74 top_level_dir = args.get('discover_top_level_dir', None)) 75 test_cases += _collect_tests(discovered_suite) 76 77 # Cleanup class functions from test classes. 78 # {class: function, ...} 79 cleanup_funcs = {} 80 81 tests = unittest.suite.TestSuite() 82 83 for test_case in test_cases: 84 if (isinstance(test_case, unittest.loader._FailedTest)): # type: ignore[attr-defined] 85 raise ValueError(f"Failed to load test: '{test_case.id()}'.") from test_case._exception 86 87 pattern = args.get('pattern', None) 88 if ((pattern is None) or re.search(pattern, test_case.id())): 89 tests.addTest(test_case) 90 91 # Check for a cleanup function. 92 if (hasattr(test_case.__class__, CLEANUP_FUNC_NAME)): 93 cleanup_funcs[test_case.__class__] = getattr(test_case.__class__, CLEANUP_FUNC_NAME) 94 else: 95 print(f"Skipping {test_case.id()} because of match pattern.") 96 97 result = runner.run(tests) 98 faults = len(result.errors) + len(result.failures) 99 100 # Perform any cleanup. 101 for cleanup_func in cleanup_funcs.values(): 102 cleanup_func() 103 104 # Cleanup the system path. 105 if (args.get('path_additions', None) is not None): 106 for path in args['path_additions']: 107 sys.path.pop() 108 109 if (not result.wasSuccessful()): 110 # This value will be used as an exit status, so it should not be larger than a byte. 111 # (Some higher values are used specially, so just keep it at a round number.) 112 return max(1, min(faults, 100)) 113 114 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:
116def main() -> int: 117 """ Parse the CLI arguments and run tests. """ 118 119 args = _get_parser().parse_args() 120 return run(args)
Parse the CLI arguments and run tests.