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.