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.