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 )