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...]', 130 help='A list of tests in the test class to execute.', 131 ) 132 parser.add_argument( 133 '-tb', 134 '--test_bed', 135 nargs='+', 136 type=str, 137 metavar='[<TEST BED NAME1> <TEST BED NAME2> ...]', 138 help='Specify which test beds to run tests on.', 139 ) 140 141 parser.add_argument( 142 '-v', 143 '--verbose', 144 action='store_true', 145 help='Set console logger level to DEBUG', 146 ) 147 if not argv: 148 argv = sys.argv[1:] 149 return parser.parse_known_args(argv)[0] 150 151 152def _find_test_class(): 153 """Finds the test class in a test script. 154 155 Walk through module members and find the subclass of BaseTestClass. Only 156 one subclass is allowed in a test script. 157 158 Returns: 159 The test class in the test module. 160 161 Raises: 162 SystemExit: Raised if the number of test classes is not exactly one. 163 """ 164 try: 165 return utils.find_subclass_in_module( 166 base_test.BaseTestClass, sys.modules['__main__'] 167 ) 168 except ValueError: 169 logging.exception( 170 'Exactly one subclass of `base_test.BaseTestClass`' 171 ' should be in the main file.' 172 ) 173 sys.exit(1) 174 175 176def _print_test_names(test_class): 177 """Prints the names of all the tests in a test module. 178 179 If the module has generated tests defined based on controller info, this 180 may not be able to print the generated tests. 181 182 Args: 183 test_class: module, the test module to print names from. 184 """ 185 cls = test_class(config_parser.TestRunConfig()) 186 test_names = [] 187 try: 188 # Executes pre-setup procedures, this is required since it might 189 # generate test methods that we want to return as well. 190 cls._pre_run() 191 if cls.tests: 192 # Specified by run list in class. 193 test_names = list(cls.tests) 194 else: 195 # No test method specified by user, list all in test class. 196 test_names = cls.get_existing_test_names() 197 except Exception: 198 logging.exception('Failed to retrieve generated tests.') 199 finally: 200 cls._clean_up() 201 print('==========> %s <==========' % cls.TAG) 202 for name in test_names: 203 print(name) 204 205 206class TestRunner: 207 """The class that instantiates test classes, executes tests, and 208 report results. 209 210 One TestRunner instance is associated with one specific output folder and 211 testbed. TestRunner.run() will generate a single set of output files and 212 results for all tests that have been added to this runner. 213 214 Attributes: 215 results: records.TestResult, object used to record the results of a test 216 run. 217 """ 218 219 class _TestRunInfo: 220 """Identifies one test class to run, which tests to run, and config to 221 run it with. 222 """ 223 224 def __init__( 225 self, config, test_class, tests=None, test_class_name_suffix=None 226 ): 227 self.config = config 228 self.test_class = test_class 229 self.test_class_name_suffix = test_class_name_suffix 230 self.tests = tests 231 232 class _TestRunMetaData: 233 """Metadata associated with a test run. 234 235 This class calculates values that are specific to a test run. 236 237 One object of this class corresponds to an entire test run, which could 238 include multiple test classes. 239 240 Attributes: 241 root_output_path: string, the root output path for a test run. All 242 artifacts from this test run shall be stored here. 243 run_id: string, the unique identifier for this test run. 244 time_elapsed_sec: float, the number of seconds elapsed for this test run. 245 """ 246 247 def __init__(self, log_dir, testbed_name): 248 self._log_dir = log_dir 249 self._testbed_name = testbed_name 250 self._logger_start_time = None 251 self._start_counter = None 252 self._end_counter = None 253 self.root_output_path = log_dir 254 255 def generate_test_run_log_path(self): 256 """Geneartes the log path for a test run. 257 258 The log path includes a timestamp that is set in this call. 259 260 There is usually a minor difference between this timestamp and the actual 261 starting point of the test run. This is because the log path must be set 262 up *before* the test run actually starts, so all information of a test 263 run can be properly captured. 264 265 The generated value can be accessed via `self.root_output_path`. 266 267 Returns: 268 String, the generated log path. 269 """ 270 self._logger_start_time = logger.get_log_file_timestamp() 271 self.root_output_path = os.path.join( 272 self._log_dir, self._testbed_name, self._logger_start_time 273 ) 274 return self.root_output_path 275 276 @property 277 def summary_file_path(self): 278 return os.path.join(self.root_output_path, records.OUTPUT_FILE_SUMMARY) 279 280 def set_start_point(self): 281 """Sets the start point of a test run. 282 283 This is used to calculate the total elapsed time of the test run. 284 """ 285 self._start_counter = time.perf_counter() 286 287 def set_end_point(self): 288 """Sets the end point of a test run. 289 290 This is used to calculate the total elapsed time of the test run. 291 """ 292 self._end_counter = time.perf_counter() 293 294 @property 295 def run_id(self): 296 """The unique identifier of a test run.""" 297 return f'{self._testbed_name}@{self._logger_start_time}' 298 299 @property 300 def time_elapsed_sec(self): 301 """The total time elapsed for a test run in seconds. 302 303 This value is None until the test run has completed. 304 """ 305 if self._start_counter is None or self._end_counter is None: 306 return None 307 return self._end_counter - self._start_counter 308 309 def __init__(self, log_dir, testbed_name): 310 """Constructor for TestRunner. 311 312 Args: 313 log_dir: string, root folder where to write logs 314 testbed_name: string, name of the testbed to run tests on 315 """ 316 self._log_dir = log_dir 317 self._testbed_name = testbed_name 318 319 self.results = records.TestResult() 320 self._test_run_infos = [] 321 self._test_run_metadata = TestRunner._TestRunMetaData(log_dir, testbed_name) 322 323 @contextlib.contextmanager 324 def mobly_logger(self, alias='latest', console_level=logging.INFO): 325 """Starts and stops a logging context for a Mobly test run. 326 327 Args: 328 alias: optional string, the name of the latest log alias directory to 329 create. If a falsy value is specified, then the directory will not 330 be created. 331 console_level: optional logging level, log level threshold used for log 332 messages printed to the console. Logs with a level less severe than 333 console_level will not be printed to the console. 334 335 Yields: 336 The host file path where the logs for the test run are stored. 337 """ 338 # Refresh the log path at the beginning of the logger context. 339 root_output_path = self._test_run_metadata.generate_test_run_log_path() 340 logger.setup_test_logger( 341 root_output_path, 342 self._testbed_name, 343 alias=alias, 344 console_level=console_level, 345 ) 346 try: 347 yield self._test_run_metadata.root_output_path 348 finally: 349 logger.kill_test_logger(logging.getLogger()) 350 351 def add_test_class(self, config, test_class, tests=None, name_suffix=None): 352 """Adds tests to the execution plan of this TestRunner. 353 354 Args: 355 config: config_parser.TestRunConfig, configuration to execute this 356 test class with. 357 test_class: class, test class to execute. 358 tests: list of strings, optional list of test names within the 359 class to execute. 360 name_suffix: string, suffix to append to the class name for 361 reporting. This is used for differentiating the same class 362 executed with different parameters in a suite. 363 364 Raises: 365 Error: if the provided config has a log_path or testbed_name which 366 differs from the arguments provided to this TestRunner's 367 constructor. 368 """ 369 if self._log_dir != config.log_path: 370 raise Error( 371 'TestRunner\'s log folder is "%s", but a test config with a ' 372 'different log folder ("%s") was added.' 373 % (self._log_dir, config.log_path) 374 ) 375 if self._testbed_name != config.testbed_name: 376 raise Error( 377 'TestRunner\'s test bed is "%s", but a test config with a ' 378 'different test bed ("%s") was added.' 379 % (self._testbed_name, config.testbed_name) 380 ) 381 self._test_run_infos.append( 382 TestRunner._TestRunInfo( 383 config=config, 384 test_class=test_class, 385 tests=tests, 386 test_class_name_suffix=name_suffix, 387 ) 388 ) 389 390 def _run_test_class(self, config, test_class, tests=None): 391 """Instantiates and executes a test class. 392 393 If tests is None, the tests listed in self.tests will be executed 394 instead. If self.tests is empty as well, every test in this test class 395 will be executed. 396 397 Args: 398 config: A config_parser.TestRunConfig object. 399 test_class: class, test class to execute. 400 tests: Optional list of test names within the class to execute. 401 """ 402 test_instance = test_class(config) 403 logging.debug( 404 'Executing test class "%s" with config: %s', test_class.__name__, config 405 ) 406 try: 407 cls_result = test_instance.run(tests) 408 self.results += cls_result 409 except signals.TestAbortAll as e: 410 self.results += e.results 411 raise e 412 413 def run(self): 414 """Executes tests. 415 416 This will instantiate controller and test classes, execute tests, and 417 print a summary. 418 419 This meethod should usually be called within the runner's `mobly_logger` 420 context. If you must use this method outside of the context, you should 421 make sure `self._test_run_metadata.generate_test_run_log_path` is called 422 before each invocation of `run`. 423 424 Raises: 425 Error: if no tests have previously been added to this runner using 426 add_test_class(...). 427 """ 428 if not self._test_run_infos: 429 raise Error('No tests to execute.') 430 431 # Officially starts the test run. 432 self._test_run_metadata.set_start_point() 433 434 # Ensure the log path exists. Necessary if `run` is used outside of the 435 # `mobly_logger` context. 436 utils.create_dir(self._test_run_metadata.root_output_path) 437 438 summary_writer = records.TestSummaryWriter( 439 self._test_run_metadata.summary_file_path 440 ) 441 442 # When a SIGTERM is received during the execution of a test, the Mobly test 443 # immediately terminates without executing any of the finally blocks. This 444 # handler converts the SIGTERM into a TestAbortAll signal so that the 445 # finally blocks will execute. We use TestAbortAll because other exceptions 446 # will be caught in the base test class and it will continue executing 447 # remaining tests. 448 def sigterm_handler(*args): 449 logging.warning('Test received a SIGTERM. Aborting all tests.') 450 raise signals.TestAbortAll('Test received a SIGTERM.') 451 452 signal.signal(signal.SIGTERM, sigterm_handler) 453 454 try: 455 for test_run_info in self._test_run_infos: 456 # Set up the test-specific config 457 test_config = test_run_info.config.copy() 458 test_config.log_path = self._test_run_metadata.root_output_path 459 test_config.summary_writer = summary_writer 460 test_config.test_class_name_suffix = ( 461 test_run_info.test_class_name_suffix 462 ) 463 try: 464 self._run_test_class( 465 config=test_config, 466 test_class=test_run_info.test_class, 467 tests=test_run_info.tests, 468 ) 469 except signals.TestAbortAll as e: 470 logging.warning('Abort all subsequent test classes. Reason: %s', e) 471 raise 472 finally: 473 summary_writer.dump( 474 self.results.summary_dict(), records.TestSummaryEntryType.SUMMARY 475 ) 476 self._test_run_metadata.set_end_point() 477 # Show the test run summary. 478 summary_lines = [ 479 f'Summary for test run {self._test_run_metadata.run_id}:', 480 f'Total time elapsed {self._test_run_metadata.time_elapsed_sec}s', 481 ( 482 'Artifacts are saved in' 483 f' "{self._test_run_metadata.root_output_path}"' 484 ), 485 ( 486 'Test summary saved in' 487 f' "{self._test_run_metadata.summary_file_path}"' 488 ), 489 f'Test results: {self.results.summary_str()}', 490 ] 491 logging.info('\n'.join(summary_lines)) 492