• 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(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