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