Experiment Unittests

experiment_unittests.py
  1"""
  2Test module for the experiment_handler/experiment_prototype code.
  3
  4This script can be run most simply via 'python3 experiment_unittests.py'. This will run through all
  5experiments defined in the Borealis experiments top level directory, experiment exception tests
  6defined in the tests/ directory, as well as any other tests hard coded into this script.
  7Any experiment that raises an exception when building will show up as a failed test here.
  8
  9This script can also be run to test individual experiments by using the --experiment flag. For
 10example: `python3 experiment_unittests.py --experiment normalscan normalsound` will only test the
 11normalscan and normalsound experiments. Any experiments specified must exist within the Borealis
 12experiments top level directory (i.e. src/borealis_experiments).
 13
 14Other command line options include:
 15
 16- Specifying what radar site to run the tests as
 17
 18References:
 19https://stackoverflow.com/questions/32899/how-do-you-generate-dynamic-parameterized-unit-tests-in-python
 20https://docs.python.org/3/library/unittest.html
 21https://www.bnmetrics.com/blog/dynamic-import-in-python3
 22
 23:copyright: 2023 SuperDARN Canada
 24:author: Kevin Krieger, Theodore Kolkman
 25"""
 26
 27import argparse
 28import random
 29import string
 30import unittest
 31import unittest.mock
 32import os
 33import sys
 34import inspect
 35import pkgutil
 36from importlib import import_module
 37import importlib.util
 38import json
 39from typing import Type
 40
 41# Need the path append to import within this file
 42BOREALISPATH = os.environ["BOREALISPATH"]
 43sys.path.append(f"{BOREALISPATH}/src")
 44
 45from utils.experiment_prototype import (
 46    ExperimentException,
 47    ExperimentPrototype,
 48    retrieve_experiment,
 49    experiment_handler,
 50)
 51from utils.options import Options
 52
 53
 54def redirect_to_devnull(func, *args, **kwargs):
 55    with open(os.devnull, "w") as devnull:
 56        old_stdout = sys.stdout
 57        old_stderr = sys.stderr
 58        sys.stdout = devnull
 59        sys.stderr = devnull
 60        try:
 61            result = func(*args, **kwargs)
 62        finally:
 63            sys.stdout = old_stdout
 64            sys.stderr = old_stderr
 65    return result
 66
 67
 68def ehmain(
 69    exp_class: Type[ExperimentPrototype], scheduling_mode="discretionary", **kwargs
 70):
 71    """
 72    Calls the functions within experiment handler that verify an experiment
 73
 74    :param  exp_class: The class of the experiment to be verified.
 75    :type   exp_class: Type[ExperimentPrototype]
 76    :param  scheduling_mode: The scheduling mode to run. Defaults to 'discretionary'
 77    :type   scheduling_mode: str
 78    :param  kwargs: The keyword arguments for the experiment
 79    :type   kwargs: dict
 80    """
 81    from utils import log_config
 82
 83    log_config.log(
 84        console=False, logfile=False, aggregator=False
 85    )  # Prevent logging in experiment
 86
 87    experiment_handler(exp_class, scheduling_mode, embargo=False, **kwargs)
 88
 89
 90class TestExperimentEnvSetup(unittest.TestCase):
 91    """
 92    A unittest class to test the environment setup for the experiment_handler module.
 93    All test methods must begin with the word 'test' to be run by unittest.
 94    """
 95
 96    def setUp(self):
 97        """
 98        This function is called before every test_* method within this class (every test case in
 99        unittest lingo)
100        """
101        print("\nMethod: ", self._testMethodName)
102
103    @unittest.mock.patch.dict(os.environ, {"BOREALISPATH": ""})
104    def test_borealispath(self):
105        """
106        Test failure to have BOREALISPATH in env
107        """
108        with self.assertRaisesRegex(ValueError, "BOREALISPATH env variable not set"):
109            normalscan = retrieve_experiment("normalscan")
110            ehmain(normalscan)
111
112    @unittest.skip("Cannot test this while hdw.dat files are in /usr/local/hdw")
113    def test_hdw_file(self):
114        """
115        Test the code that checks for the hdw.dat file
116        """
117        options = Options()
118        site_name = options.site_id
119        hdw_path = options.hdw_path
120
121        # Rename the hdw.dat file temporarily
122        os.rename(f"{hdw_path}/hdw.dat.{site_name}", f"{hdw_path}/_hdw.dat.{site_name}")
123
124        with self.assertRaisesRegex(ValueError, "Cannot open hdw.dat.[a-z]{3} file at"):
125            normalscan = retrieve_experiment("normalscan")
126            ehmain(normalscan)
127
128        # Now rename the hdw.dat file and move on
129        os.rename(f"{hdw_path}/_hdw.dat.{site_name}", f"{hdw_path}/hdw.dat.{site_name}")
130
131
132class TestExperimentRetrieval(unittest.TestCase):
133    """
134    A class to test that `retrieve_experiment()` works properly.
135    """
136
137    def setUp(self):
138        """
139        This function is called before every test case.
140        """
141        print("\nRetrieve Experiment test: ", self._testMethodName)
142
143    def test_normalscan(self):
144        exp = retrieve_experiment("normalscan")
145        assert exp.__name__, "NormalScan"
146
147    def test_two_classes(self):
148        with self.assertRaisesRegex(
149            ExperimentException,
150            "You have more than one experiment class in your experiment file - exiting",
151        ):
152            retrieve_experiment("tests.beam_order")
153
154    def test_nonexistent_file(self):
155        with self.assertRaisesRegex(
156            ModuleNotFoundError, "No module named 'borealis_experiments.dummy'"
157        ):
158            retrieve_experiment("dummy")
159
160    def test_module_with_extra_attributes(self):
161        retrieve_experiment("full_fov")
162
163
164class TestMockExperiments(unittest.TestCase):
165    """
166    A unittest class to test various ways for an experiment to fail for the experiment_handler
167    module. Tests will check that exceptions are correctly thrown for each failure case. All test
168    methods must begin with the word 'test' to be run by unittest.
169    """
170
171    def setUp(self):
172        """
173        This function is called before every test_* method (every test case in unittest lingo)
174        """
175        print("\nException Test: ", self._testMethodName)
176
177
178class TestActiveExperiments(unittest.TestCase):
179    """
180    A unittest class to test all Borealis experiments and verify that none of them are built
181    incorrectly. Tests are verified using code within experiment handler. All test methods must
182    begin with the word 'test' to be run by unittest.
183    """
184
185    def setUp(self):
186        """
187        This function is called before every test_* method (every test case in unittest lingo)
188        """
189        print("\nExperiment Test: ", self._testMethodName)
190
191
192def build_unit_tests():
193    """
194    Create individual unit tests for all test cases specified in tests/ directory of experiments path.
195    """
196
197    experiment_package = "tests"
198    experiment_path = f"{BOREALISPATH}/src/borealis_experiments/{experiment_package}/"
199    if not os.path.exists(experiment_path):
200        raise OSError(f"Error: experiment path {experiment_path} is invalid")
201
202    # Iterate through all modules in the borealis_experiments directory
203    for _, name, _ in pkgutil.iter_modules([experiment_path]):
204        imported_module = import_module(
205            "." + name, package=f"borealis_experiments.{experiment_package}"
206        )
207        # Loop through all attributes of each found module
208        for i in dir(imported_module):
209            attribute = getattr(imported_module, i)
210            # To verify that an attribute is a runnable experiment, check that the attribute is
211            # a class and inherits from ExperimentPrototype
212            if inspect.isclass(attribute) and issubclass(
213                attribute, ExperimentPrototype
214            ):
215                # Only create a test if the current attribute is the experiment itself
216                if "ExperimentPrototype" not in str(attribute):
217                    if hasattr(attribute, "error_message"):
218                        # If expected to fail, should have a classmethod called "error_message"
219                        # that contains the error message raised
220                        exp_exception, msg = getattr(attribute, "error_message")()
221                        test = exception_test_generator(attribute, exp_exception, msg)
222                    else:  # No exception expected - this is a positive test
223                        test = experiment_test_generator(attribute)
224                    # setattr makes a properly named test method within TestExperimentArchive which
225                    # can be run by unittest.main()
226                    if hasattr(TestActiveExperiments, f"test_{attribute.__name__}"):
227                        raise ValueError(
228                            f"Multiple tests have name test_{attribute.__name__}"
229                        )
230                    setattr(TestMockExperiments, f"test_{attribute.__name__}", test)
231
232
233def exception_test_generator(
234    exp_class: Type[ExperimentPrototype], exception, exception_message
235):
236    """
237    Generate a single test for the given module name and exception message
238
239    :param exp_class:           Experiment class
240    :type  exp_class:           Type[ExperimentPrototype]
241    :param exception:           Exception that is expected to be raised
242    :type  exception:           BaseException
243    :param exception_message:   Message from the Exception raised.
244    :type  exception_message:   str
245    """
246
247    def test(self):
248        with self.assertRaisesRegex(exception, exception_message):
249            redirect_to_devnull(ehmain, exp_class)
250
251    return test
252
253
254def build_experiment_tests(experiments=None, kwargs=None):
255    """
256    Create individual unit tests for all experiments within the base borealis_experiments/
257    directory. All experiments are run to ensure no exceptions are thrown when they are built.
258    """
259
260    experiment_package = "borealis_experiments"
261    experiment_path = f"{BOREALISPATH}/src/{experiment_package}/"
262    if not os.path.exists(experiment_path):
263        raise OSError(f"Error: experiment path {experiment_path} is invalid")
264
265    # parse kwargs and pass to experiment
266    kwargs_dict = {}
267    if kwargs:
268        for element in kwargs:
269            if element == "":
270                continue
271            kwarg = element.split("=")
272            if len(kwarg) == 2:
273                kwargs_dict[kwarg[0]] = kwarg[1]
274            else:
275                raise ValueError(f"Bad kwarg: {element}")
276
277    def add_experiment_test(exp_name: str):
278        """Add a unit test for a given experiment"""
279        imported_module = import_module("." + exp_name, package=experiment_package)
280        # Loop through all attributes of each found module
281        for i in dir(imported_module):
282            attribute = getattr(imported_module, i)
283            # To verify that an attribute is a runnable experiment, check that the attribute is
284            # a class and inherits from ExperimentPrototype
285            if inspect.isclass(attribute) and issubclass(
286                attribute, ExperimentPrototype
287            ):
288                # Only create a test if the current attribute is the experiment itself
289                if "ExperimentPrototype" not in str(attribute):
290                    test = experiment_test_generator(attribute, **kwargs_dict)
291                    # setattr make the "test" function a method within TestActiveExperiments called
292                    # "test_[exp_name]" which can be run via unittest.main()
293                    test_name = f"test_{exp_name}"
294                    if hasattr(TestActiveExperiments, test_name):
295                        test_name += "".join(
296                            random.choice(string.ascii_lowercase) for _ in range(6)
297                        )
298                    setattr(TestActiveExperiments, f"test_{exp_name}", test)
299
300    # Grab the experiments specified
301    if experiments is not None:
302        for name in experiments:
303            spec = importlib.util.find_spec("." + name, package=experiment_package)
304            if spec is None:
305                # Add in a failing test for this experiment name
306                setattr(
307                    TestActiveExperiments,
308                    f"test_{name}",
309                    lambda self: self.fail("Experiment not found"),
310                )
311            else:
312                add_experiment_test(name)
313
314    else:
315        # Iterate through all modules in the borealis_experiments directory
316        for _, name, _ in pkgutil.iter_modules([experiment_path]):
317            add_experiment_test(name)
318
319
320def experiment_test_generator(exp_class: Type[ExperimentPrototype], **kwargs):
321    """
322    Generate a single test for a given experiment name. The test will try to run the experiment,
323    and if any exceptions are thrown (i.e. the experiment is built incorrectly) the test will fail.
324
325    :param exp_class: Experiment class
326    :type  exp_class: Type[ExperimentPrototype]
327    """
328
329    def test(self):
330        try:
331            redirect_to_devnull(ehmain, exp_class, **kwargs)
332        except Exception as err:
333            self.fail(err)
334
335    return test
336
337
338def run_tests(raw_args=None, buffer=True, print_results=True):
339    parser = argparse.ArgumentParser()
340    parser.add_argument(
341        "--experiments",
342        required=False,
343        nargs="+",
344        default=None,
345        help="Only run the experiments specified after this option. Experiments \
346                            specified must exist within the top-level Borealis experiments directory.",
347    )
348    parser.add_argument(
349        "--kwargs",
350        required=False,
351        nargs="+",
352        default=list(),
353        help="Keyword arguments to pass to the experiments. Note that kwargs are passed to all "
354        "experiments specified.",
355    )
356    parser.add_argument(
357        "--module",
358        required=False,
359        default="__main__",
360        help="If calling from another python file, this should be set to "
361        "'experiment_unittests' in order to properly work.",
362    )
363    parser.add_argument(
364        "--no-tests",
365        required=False,
366        action="store_true",
367        help="Only test the main experiments, not those in tests/",
368    )
369    verbose_group = parser.add_mutually_exclusive_group()
370    verbose_group.add_argument(
371        "-v",
372        "--verbose",
373        action="store_true",
374        help="Increase verbosity",
375    )
376    verbose_group.add_argument(
377        "-q",
378        "--quiet",
379        action="store_true",
380        help="Decrease verbosity",
381    )
382    args = parser.parse_args(raw_args)
383
384    # Read in config.ini file for current site to make necessary directories
385    path = (
386        f"{os.environ['BOREALISPATH']}/config/"
387        f"{os.environ['RADAR_ID']}/"
388        f"{os.environ['RADAR_ID']}_config.ini"
389    )
390    try:
391        with open(path, "r") as data:
392            raw_config = json.load(data)
393    except OSError:
394        errmsg = f"Cannot open config file at {path}"
395        raise ValueError(errmsg)
396
397    # These directories are required for ExperimentHandler to run
398    data_directory = raw_config["data_directory"]
399    log_directory = raw_config["log_handlers"]["logfile"]["directory"]
400    hdw_path = raw_config["hdw_path"]
401    hdw_dat_file = f"{hdw_path}/hdw.dat.{os.environ['RADAR_ID']}"
402    if not os.path.exists(data_directory):
403        os.makedirs(data_directory)
404    if not os.path.exists(log_directory):
405        os.makedirs(log_directory)
406    if not os.path.exists(hdw_path):
407        os.makedirs(hdw_path)
408    if not os.path.exists(hdw_dat_file):
409        open(hdw_dat_file, "w")
410
411    experiments = args.experiments
412    if experiments is None:  # Run all unit tests and experiment tests
413        print("Running tests on all experiments")
414        if not args.no_tests:
415            build_unit_tests()
416        build_experiment_tests()
417        argv = [sys.argv[0]]
418    else:  # Only test specified experiments
419        print(f"Running tests on experiments {experiments}")
420        build_experiment_tests(experiments, args.kwargs)
421        exp_tests = []
422        for exp in experiments:
423            # Check experiment exists
424            if hasattr(TestActiveExperiments, f"test_{exp}"):
425                # Create correct string to test the experiment with unittest
426                exp_tests.append(f"TestActiveExperiments.test_{exp}")
427            else:
428                print(f"Could not find experiment {exp}. Exiting...")
429                exit(1)
430        argv = [parser.prog] + exp_tests
431    if print_results:
432        if args.quiet:
433            verbosity = 0
434        if args.verbose:
435            verbosity = 2
436        else:
437            verbosity = 1
438        result = unittest.main(
439            module=args.module,
440            argv=argv,
441            exit=False,
442            buffer=buffer,
443            verbosity=verbosity,
444        )
445    else:
446        result = redirect_to_devnull(
447            unittest.main, module=args.module, argv=argv, exit=False, buffer=buffer
448        )
449
450    # Clean up the directories/files we created
451    try:
452        os.removedirs(data_directory)
453    except OSError:  # If directories not empty, this will fail. That is fine.
454        pass
455    try:
456        os.removedirs(log_directory)
457    except OSError:  # If directories not empty, this will fail. That is fine.
458        pass
459    if os.path.getsize(hdw_dat_file) == 0:
460        try:
461            os.remove(hdw_dat_file)
462        except OSError:  # Path is a directory
463            os.removedirs(hdw_dat_file)
464        else:  # File removed, now clean up directories
465            try:
466                os.removedirs(hdw_path)
467            except OSError:  # If directories not empty, this will fail. That is fine.
468                pass
469
470    if isinstance(result, unittest.TestProgram):
471        result = result.result
472
473    return result
474
475
476if __name__ == "__main__":
477    from utils import log_config
478
479    log = log_config.log(console=False, logfile=False, aggregator=False)
480
481    run_tests(sys.argv[1:])

This script tests both runnable experiments (those in borealis/src/borealis_experiments/) and a set of unit tests (those in borealis/src/borealis_experiments/tests/). Some unit tests are meant to raise an exception; these tests have an extra method defined which returns the expected exception and a regex of the expected error message. An example unit test is shown below.

Example Unit Test file
 1import borealis_experiments.superdarn_common_fields as scf
 2from utils.experiment_prototype import ExperimentPrototype
 3from pydantic import ValidationError
 4
 5
 6class AveMethodDNE(ExperimentPrototype):
 7    cpid = 1
 8
 9    def __init__(self):
10        super().__init__()
11        self.add_slice(
12            {
13                "pulse_sequence": scf.SEQUENCE_7P,
14                "tau_spacing": scf.TAU_SPACING_7P,
15                "pulse_len": scf.PULSE_LEN_45KM,
16                "num_ranges": scf.STD_NUM_RANGES,
17                "first_range": scf.STD_FIRST_RANGE,
18                "intt": scf.INTT_MS,
19                "beam_angle": scf.STD_BEAM_ANGLES,
20                "rx_beam_order": scf.STD_BEAM_ORDER,
21                "tx_beam_order": scf.STD_BEAM_ORDER,
22                "freq": scf.COMMON_MODE_FREQ_1,
23                "acf": True,
24                "averaging_method": "not_a_method",  # This is not a valid method
25            }
26        )
27
28    @classmethod
29    def error_message(cls):
30        return (
31            ValidationError,
32            "averaging_method\n" "  Input should be 'mean' or 'median'",
33        )