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