1# Copyright 2016 Google Inc. 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15import argparse 16import contextlib 17import logging 18import os 19import signal 20import sys 21import time 22 23from mobly import base_test 24from mobly import config_parser 25from mobly import logger 26from mobly import records 27from mobly import signals 28from mobly import utils 29 30 31class Error(Exception): 32 pass 33 34 35def main(argv=None): 36 """Execute the test class in a test module. 37 38 This is the default entry point for running a test script file directly. 39 In this case, only one test class in a test script is allowed. 40 41 To make your test script executable, add the following to your file: 42 43 .. code-block:: python 44 45 from mobly import test_runner 46 ... 47 if __name__ == '__main__': 48 test_runner.main() 49 50 If you want to implement your own cli entry point, you could use function 51 execute_one_test_class(test_class, test_config, test_identifier) 52 53 Args: 54 argv: A list that is then parsed as cli args. If None, defaults to cli 55 input. 56 """ 57 args = parse_mobly_cli_args(argv) 58 # Find the test class in the test script. 59 test_class = _find_test_class() 60 if args.list_tests: 61 _print_test_names(test_class) 62 sys.exit(0) 63 # Load test config file. 64 test_configs = config_parser.load_test_config_file(args.config, args.test_bed) 65 # Parse test specifiers if exist. 66 tests = None 67 if args.tests: 68 tests = args.tests 69 console_level = logging.DEBUG if args.verbose else logging.INFO 70 # Execute the test class with configs. 71 ok = True 72 for config in test_configs: 73 runner = TestRunner( 74 log_dir=config.log_path, testbed_name=config.testbed_name 75 ) 76 with runner.mobly_logger(console_level=console_level): 77 runner.add_test_class(config, test_class, tests) 78 try: 79 runner.run() 80 ok = runner.results.is_all_pass and ok 81 except signals.TestAbortAll: 82 pass 83 except Exception: 84 logging.exception('Exception when executing %s.', config.testbed_name) 85 ok = False 86 if not ok: 87 sys.exit(1) 88 89 90def parse_mobly_cli_args(argv): 91 """Parses cli args that are consumed by Mobly. 92 93 This is the arg parsing logic for the default test_runner.main entry point. 94 95 Multiple arg parsers can be applied to the same set of cli input. So you 96 can use this logic in addition to any other args you want to parse. This 97 function ignores the args that don't apply to default `test_runner.main`. 98 99 Args: 100 argv: A list that is then parsed as cli args. If None, defaults to cli 101 input. 102 103 Returns: 104 Namespace containing the parsed args. 105 """ 106 parser = argparse.ArgumentParser(description='Mobly Test Executable.') 107 group = parser.add_mutually_exclusive_group(required=True) 108 group.add_argument( 109 '-c', 110 '--config', 111 type=str, 112 metavar='<PATH>', 113 help='Path to the test configuration file.', 114 ) 115 group.add_argument( 116 '-l', 117 '--list_tests', 118 action='store_true', 119 help=( 120 'Print the names of the tests defined in a script without ' 121 'executing them.' 122 ), 123 ) 124 parser.add_argument( 125 '--tests', 126 '--test_case', 127 nargs='+', 128 type=str, 129 metavar='[test_a test_b re:test_(c|d)...]', 130 help=( 131 'A list of tests in the test class to execute. Each value can be a ' 132 'test name string or a `re:` prefixed string for full regex match of' 133 ' test names.' 134 ), 135 ) 136 parser.add_argument( 137 '-tb', 138 '--test_bed', 139 nargs='+', 140 type=str, 141 metavar='[<TEST BED NAME1> <TEST BED NAME2> ...]', 142 help='Specify which test beds to run tests on.', 143 ) 144 145 parser.add_argument( 146 '-v', 147 '--verbose', 148 action='store_true', 149 help='Set console logger level to DEBUG', 150 ) 151 if not argv: 152 argv = sys.argv[1:] 153 return parser.parse_known_args(argv)[0] 154 155 156def _find_test_class(): 157 """Finds the test class in a test script. 158 159 Walk through module members and find the subclass of BaseTestClass. Only 160 one subclass is allowed in a test script. 161 162 Returns: 163 The test class in the test module. 164 165 Raises: 166 SystemExit: Raised if the number of test classes is not exactly one. 167 """ 168 try: 169 return utils.find_subclass_in_module( 170 base_test.BaseTestClass, sys.modules['__main__'] 171 ) 172 except ValueError: 173 logging.exception( 174 'Exactly one subclass of `base_test.BaseTestClass`' 175 ' should be in the main file.' 176 ) 177 sys.exit(1) 178 179 180def _print_test_names(test_class): 181 """Prints the names of all the tests in a test module. 182 183 If the module has generated tests defined based on controller info, this 184 may not be able to print the generated tests. 185 186 Args: 187 test_class: module, the test module to print names from. 188 """ 189 cls = test_class(config_parser.TestRunConfig()) 190 test_names = [] 191 try: 192 # Executes pre-setup procedures, this is required since it might 193 # generate test methods that we want to return as well. 194 cls._pre_run() 195 if cls.tests: 196 # Specified by run list in class. 197 test_names = list(cls.tests) 198 else: 199 # No test method specified by user, list all in test class. 200 test_names = cls.get_existing_test_names() 201 except Exception: 202 logging.exception('Failed to retrieve generated tests.') 203 finally: 204 cls._clean_up() 205 print('==========> %s <==========' % cls.TAG) 206 for name in test_names: 207 print(name) 208 209 210class TestRunner: 211 """The class that instantiates test classes, executes tests, and 212 report results. 213 214 One TestRunner instance is associated with one specific output folder and 215 testbed. TestRunner.run() will generate a single set of output files and 216 results for all tests that have been added to this runner. 217 218 Attributes: 219 results: records.TestResult, object used to record the results of a test 220 run. 221 """ 222 223 class _TestRunInfo: 224 """Identifies one test class to run, which tests to run, and config to 225 run it with. 226 """ 227 228 def __init__( 229 self, config, test_class, tests=None, test_class_name_suffix=None 230 ): 231 self.config = config 232 self.test_class = test_class 233 self.test_class_name_suffix = test_class_name_suffix 234 self.tests = tests 235 236 class _TestRunMetaData: 237 """Metadata associated with a test run. 238 239 This class calculates values that are specific to a test run. 240 241 One object of this class corresponds to an entire test run, which could 242 include multiple test classes. 243 244 Attributes: 245 root_output_path: string, the root output path for a test run. All 246 artifacts from this test run shall be stored here. 247 run_id: string, the unique identifier for this test run. 248 time_elapsed_sec: float, the number of seconds elapsed for this test run. 249 """ 250 251 def __init__(self, log_dir, testbed_name): 252 self._log_dir = log_dir 253 self._testbed_name = testbed_name 254 self._logger_start_time = None 255 self._start_counter = None 256 self._end_counter = None 257 self.root_output_path = log_dir 258 259 def generate_test_run_log_path(self): 260 """Geneartes the log path for a test run. 261 262 The log path includes a timestamp that is set in this call. 263 264 There is usually a minor difference between this timestamp and the actual 265 starting point of the test run. This is because the log path must be set 266 up *before* the test run actually starts, so all information of a test 267 run can be properly captured. 268 269 The generated value can be accessed via `self.root_output_path`. 270 271 Returns: 272 String, the generated log path. 273 """ 274 self._logger_start_time = logger.get_log_file_timestamp() 275 self.root_output_path = os.path.join( 276 self._log_dir, self._testbed_name, self._logger_start_time 277 ) 278 return self.root_output_path 279 280 @property 281 def summary_file_path(self): 282 return os.path.join(self.root_output_path, records.OUTPUT_FILE_SUMMARY) 283 284 def set_start_point(self): 285 """Sets the start point of a test run. 286 287 This is used to calculate the total elapsed time of the test run. 288 """ 289 self._start_counter = time.perf_counter() 290 291 def set_end_point(self): 292 """Sets the end point of a test run. 293 294 This is used to calculate the total elapsed time of the test run. 295 """ 296 self._end_counter = time.perf_counter() 297 298 @property 299 def run_id(self): 300 """The unique identifier of a test run.""" 301 return f'{self._testbed_name}@{self._logger_start_time}' 302 303 @property 304 def time_elapsed_sec(self): 305 """The total time elapsed for a test run in seconds. 306 307 This value is None until the test run has completed. 308 """ 309 if self._start_counter is None or self._end_counter is None: 310 return None 311 return self._end_counter - self._start_counter 312 313 def get_full_test_names(self): 314 """Returns the names of all tests that will be run in this test runner. 315 316 Returns: 317 A list of test names. Each test name is in the format of 318 <test.TAG>.<test_name>. 319 """ 320 test_names = [] 321 for test_run_info in self._test_run_infos: 322 test_config = test_run_info.config.copy() 323 test_config.test_class_name_suffix = test_run_info.test_class_name_suffix 324 test = test_run_info.test_class(test_config) 325 326 tests = self._get_test_names_from_class(test) 327 if test_run_info.tests is not None: 328 # If tests is provided, verify that all tests exist in the class. 329 tests_set = set(tests) 330 for test_name in test_run_info.tests: 331 if test_name not in tests_set: 332 raise Error( 333 'Unknown test method: %s in class %s', (test_name, test.TAG) 334 ) 335 test_names.append(f'{test.TAG}.{test_name}') 336 else: 337 test_names.extend([f'{test.TAG}.{n}' for n in tests]) 338 339 return test_names 340 341 def _get_test_names_from_class(self, test): 342 """Returns the names of all the tests in a test class. 343 344 Args: 345 test: module, the test module to print names from. 346 """ 347 try: 348 # Executes pre-setup procedures, this is required since it might 349 # generate test methods that we want to return as well. 350 test._pre_run() 351 if test.tests: 352 # Specified by run list in class. 353 return list(test.tests) 354 else: 355 # No test method specified by user, list all in test class. 356 return test.get_existing_test_names() 357 finally: 358 test._clean_up() 359 360 def __init__(self, log_dir, testbed_name): 361 """Constructor for TestRunner. 362 363 Args: 364 log_dir: string, root folder where to write logs 365 testbed_name: string, name of the testbed to run tests on 366 """ 367 self._log_dir = log_dir 368 self._testbed_name = testbed_name 369 370 self.results = records.TestResult() 371 self._test_run_infos = [] 372 self._test_run_metadata = TestRunner._TestRunMetaData(log_dir, testbed_name) 373 374 @contextlib.contextmanager 375 def mobly_logger(self, alias='latest', console_level=logging.INFO): 376 """Starts and stops a logging context for a Mobly test run. 377 378 Args: 379 alias: optional string, the name of the latest log alias directory to 380 create. If a falsy value is specified, then the directory will not 381 be created. 382 console_level: optional logging level, log level threshold used for log 383 messages printed to the console. Logs with a level less severe than 384 console_level will not be printed to the console. 385 386 Yields: 387 The host file path where the logs for the test run are stored. 388 """ 389 # Refresh the log path at the beginning of the logger context. 390 root_output_path = self._test_run_metadata.generate_test_run_log_path() 391 logger.setup_test_logger( 392 root_output_path, 393 self._testbed_name, 394 alias=alias, 395 console_level=console_level, 396 ) 397 try: 398 yield self._test_run_metadata.root_output_path 399 finally: 400 logger.kill_test_logger(logging.getLogger()) 401 402 def add_test_class(self, config, test_class, tests=None, name_suffix=None): 403 """Adds tests to the execution plan of this TestRunner. 404 405 Args: 406 config: config_parser.TestRunConfig, configuration to execute this 407 test class with. 408 test_class: class, test class to execute. 409 tests: list of strings, optional list of test names within the 410 class to execute. 411 name_suffix: string, suffix to append to the class name for 412 reporting. This is used for differentiating the same class 413 executed with different parameters in a suite. 414 415 Raises: 416 Error: if the provided config has a log_path or testbed_name which 417 differs from the arguments provided to this TestRunner's 418 constructor. 419 """ 420 if self._log_dir != config.log_path: 421 raise Error( 422 'TestRunner\'s log folder is "%s", but a test config with a ' 423 'different log folder ("%s") was added.' 424 % (self._log_dir, config.log_path) 425 ) 426 if self._testbed_name != config.testbed_name: 427 raise Error( 428 'TestRunner\'s test bed is "%s", but a test config with a ' 429 'different test bed ("%s") was added.' 430 % (self._testbed_name, config.testbed_name) 431 ) 432 self._test_run_infos.append( 433 TestRunner._TestRunInfo( 434 config=config, 435 test_class=test_class, 436 tests=tests, 437 test_class_name_suffix=name_suffix, 438 ) 439 ) 440 441 def _run_test_class(self, config, test_class, tests=None): 442 """Instantiates and executes a test class. 443 444 If tests is None, the tests listed in self.tests will be executed 445 instead. If self.tests is empty as well, every test in this test class 446 will be executed. 447 448 Args: 449 config: A config_parser.TestRunConfig object. 450 test_class: class, test class to execute. 451 tests: Optional list of test names within the class to execute. 452 """ 453 test_instance = test_class(config) 454 logging.debug( 455 'Executing test class "%s" with config: %s', test_class.__name__, config 456 ) 457 try: 458 cls_result = test_instance.run(tests) 459 self.results += cls_result 460 except signals.TestAbortAll as e: 461 self.results += e.results 462 raise e 463 464 def run(self): 465 """Executes tests. 466 467 This will instantiate controller and test classes, execute tests, and 468 print a summary. 469 470 This meethod should usually be called within the runner's `mobly_logger` 471 context. If you must use this method outside of the context, you should 472 make sure `self._test_run_metadata.generate_test_run_log_path` is called 473 before each invocation of `run`. 474 475 Raises: 476 Error: if no tests have previously been added to this runner using 477 add_test_class(...). 478 """ 479 if not self._test_run_infos: 480 raise Error('No tests to execute.') 481 482 # Officially starts the test run. 483 self._test_run_metadata.set_start_point() 484 485 # Ensure the log path exists. Necessary if `run` is used outside of the 486 # `mobly_logger` context. 487 utils.create_dir(self._test_run_metadata.root_output_path) 488 489 summary_writer = records.TestSummaryWriter( 490 self._test_run_metadata.summary_file_path 491 ) 492 493 # When a SIGTERM is received during the execution of a test, the Mobly test 494 # immediately terminates without executing any of the finally blocks. This 495 # handler converts the SIGTERM into a TestAbortAll signal so that the 496 # finally blocks will execute. We use TestAbortAll because other exceptions 497 # will be caught in the base test class and it will continue executing 498 # remaining tests. 499 def sigterm_handler(*args): 500 logging.warning('Test received a SIGTERM. Aborting all tests.') 501 raise signals.TestAbortAll('Test received a SIGTERM.') 502 503 signal.signal(signal.SIGTERM, sigterm_handler) 504 505 try: 506 for test_run_info in self._test_run_infos: 507 # Set up the test-specific config 508 test_config = test_run_info.config.copy() 509 test_config.log_path = self._test_run_metadata.root_output_path 510 test_config.summary_writer = summary_writer 511 test_config.test_class_name_suffix = ( 512 test_run_info.test_class_name_suffix 513 ) 514 try: 515 self._run_test_class( 516 config=test_config, 517 test_class=test_run_info.test_class, 518 tests=test_run_info.tests, 519 ) 520 except signals.TestAbortAll as e: 521 logging.warning('Abort all subsequent test classes. Reason: %s', e) 522 raise 523 finally: 524 summary_writer.dump( 525 self.results.summary_dict(), records.TestSummaryEntryType.SUMMARY 526 ) 527 self._test_run_metadata.set_end_point() 528 # Show the test run summary. 529 summary_lines = [ 530 f'Summary for test run {self._test_run_metadata.run_id}:', 531 f'Total time elapsed {self._test_run_metadata.time_elapsed_sec}s', 532 ( 533 'Artifacts are saved in' 534 f' "{self._test_run_metadata.root_output_path}"' 535 ), 536 ( 537 'Test summary saved in' 538 f' "{self._test_run_metadata.summary_file_path}"' 539 ), 540 f'Test results: {self.results.summary_str()}', 541 ] 542 logging.info('\n'.join(summary_lines)) 543