• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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