• 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"""This module has classes for test result collection, and test result output.
15"""
16
17import collections
18import copy
19import enum
20import functools
21import io
22import logging
23import threading
24import time
25import traceback
26import yaml
27
28from mobly import signals
29from mobly import utils
30
31# File names for the output files.
32OUTPUT_FILE_INFO_LOG = 'test_log.INFO'
33OUTPUT_FILE_DEBUG_LOG = 'test_log.DEBUG'
34OUTPUT_FILE_SUMMARY = 'test_summary.yaml'
35
36
37class Error(Exception):
38  """Raised for errors in record module members."""
39
40
41def uid(uid):
42  """Decorator specifying the unique identifier (UID) of a test case.
43
44  The UID will be recorded in the test's record when executed by Mobly.
45
46  If you use any other decorator for the test method, you may want to use
47  this as the outer-most one.
48
49  Note a common UID system is the Universal Unitque Identifier (UUID), but
50  we are not limiting people to use UUID, hence the more generic name `UID`.
51
52  Args:
53    uid: string, the uid for the decorated test function.
54  """
55  if uid is None:
56    raise ValueError('UID cannot be None.')
57
58  def decorate(test_func):
59
60    @functools.wraps(test_func)
61    def wrapper(*args, **kwargs):
62      return test_func(*args, **kwargs)
63
64    setattr(wrapper, 'uid', uid)
65    return wrapper
66
67  return decorate
68
69
70class TestSummaryEntryType(enum.Enum):
71  """Constants used to identify the type of entries in test summary file.
72
73  Test summary file contains multiple yaml documents. In order to parse this
74  file efficiently, the write adds the type of each entry when it writes the
75  entry to the file.
76
77  The idea is similar to how `TestResult.json_str` categorizes different
78  sections of a `TestResult` object in the serialized format.
79  """
80  # A list of all the tests requested for a test run.
81  # This is dumped at the beginning of a summary file so we know what was
82  # requested in case the test is interrupted and the final summary is not
83  # created.
84  TEST_NAME_LIST = 'TestNameList'
85  # Records of test results.
86  RECORD = 'Record'
87  # A summary of the test run stats, e.g. how many test failed.
88  SUMMARY = 'Summary'
89  # Information on the controllers used in a test class.
90  CONTROLLER_INFO = 'ControllerInfo'
91  # Additional data added by users during test.
92  # This can be added at any point in the test, so do not assume the location
93  # of these entries in the summary file.
94  USER_DATA = 'UserData'
95
96
97class TestSummaryWriter:
98  """Writer for the test result summary file of a test run.
99
100  For each test run, a writer is created to stream test results to the
101  summary file on disk.
102
103  The serialization and writing of the `TestResult` object is intentionally
104  kept out of `TestResult` class and put in this class. Because `TestResult`
105  can be operated on by suites, like `+` operation, and it is difficult to
106  guarantee the consistency between `TestResult` in memory and the files on
107  disk. Also, this separation makes it easier to provide a more generic way
108  for users to consume the test summary, like via a database instead of a
109  file.
110  """
111
112  def __init__(self, path):
113    self._path = path
114    self._lock = threading.Lock()
115
116  def __copy__(self):
117    """Make a "copy" of the object.
118
119    The writer is merely a wrapper object for a path with a global lock for
120    write operation. So we simply return the object itself for copy
121    operations.
122    """
123    return self
124
125  def __deepcopy__(self, *args):
126    return self.__copy__()
127
128  def dump(self, content, entry_type):
129    """Dumps a dictionary as a yaml document to the summary file.
130
131    Each call to this method dumps a separate yaml document to the same
132    summary file associated with a test run.
133
134    The content of the dumped dictionary has an extra field `TYPE` that
135    specifies the type of each yaml document, which is the flag for parsers
136    to identify each document.
137
138    Args:
139      content: dictionary, the content to serialize and write.
140      entry_type: a member of enum TestSummaryEntryType.
141
142    Raises:
143      recoreds.Error: An invalid entry type is passed in.
144    """
145    new_content = copy.deepcopy(content)
146    new_content['Type'] = entry_type.value
147    # Both user code and Mobly code can trigger this dump, hence the lock.
148    with self._lock:
149      # For Python3, setting the encoding on yaml.safe_dump does not work
150      # because Python3 file descriptors set an encoding by default, which
151      # PyYAML uses instead of the encoding on yaml.safe_dump. So, the
152      # encoding has to be set on the open call instead.
153      with io.open(self._path, 'a', encoding='utf-8') as f:
154        # Use safe_dump here to avoid language-specific tags in final
155        # output.
156        yaml.safe_dump(new_content,
157                       f,
158                       explicit_start=True,
159                       allow_unicode=True,
160                       indent=4)
161
162
163class TestResultEnums:
164  """Enums used for TestResultRecord class.
165
166  Includes the tokens to mark test result with, and the string names for each
167  field in TestResultRecord.
168  """
169
170  RECORD_NAME = 'Test Name'
171  RECORD_CLASS = 'Test Class'
172  RECORD_BEGIN_TIME = 'Begin Time'
173  RECORD_END_TIME = 'End Time'
174  RECORD_RESULT = 'Result'
175  RECORD_UID = 'UID'
176  RECORD_EXTRAS = 'Extras'
177  RECORD_EXTRA_ERRORS = 'Extra Errors'
178  RECORD_DETAILS = 'Details'
179  RECORD_STACKTRACE = 'Stacktrace'
180  RECORD_SIGNATURE = 'Signature'
181  RECORD_RETRY_PARENT = 'Retry Parent'
182  RECORD_POSITION = 'Position'
183  TEST_RESULT_PASS = 'PASS'
184  TEST_RESULT_FAIL = 'FAIL'
185  TEST_RESULT_SKIP = 'SKIP'
186  TEST_RESULT_ERROR = 'ERROR'
187
188
189class ControllerInfoRecord:
190  """A record representing the controller info in test results."""
191
192  KEY_TEST_CLASS = TestResultEnums.RECORD_CLASS
193  KEY_CONTROLLER_NAME = 'Controller Name'
194  KEY_CONTROLLER_INFO = 'Controller Info'
195  KEY_TIMESTAMP = 'Timestamp'
196
197  def __init__(self, test_class, controller_name, info):
198    self.test_class = test_class
199    self.controller_name = controller_name
200    self.controller_info = info
201    self.timestamp = time.time()
202
203  def to_dict(self):
204    result = {}
205    result[self.KEY_TEST_CLASS] = self.test_class
206    result[self.KEY_CONTROLLER_NAME] = self.controller_name
207    result[self.KEY_CONTROLLER_INFO] = self.controller_info
208    result[self.KEY_TIMESTAMP] = self.timestamp
209    return result
210
211  def __repr__(self):
212    return str(self.to_dict())
213
214
215class ExceptionRecord:
216  """A record representing exception objects in TestResultRecord.
217
218  Attributes:
219    exception: Exception object, the original Exception.
220    stacktrace: string, stacktrace of the Exception.
221    extras: optional serializable, this corresponds to the
222      `TestSignal.extras` field.
223    position: string, an optional label specifying the position where the
224      Exception ocurred.
225  """
226
227  def __init__(self, e, position=None):
228    self.exception = e
229    self.stacktrace = None
230    self.extras = None
231    self.position = position
232    self.is_test_signal = isinstance(e, signals.TestSignal)
233    # Record stacktrace of the exception.
234    # This check cannot be based on try...except, which messes up
235    # `exc_info`.
236    exc_traceback = e.__traceback__
237    if exc_traceback:
238      self.stacktrace = ''.join(
239          traceback.format_exception(e.__class__, e, exc_traceback))
240    # Populate fields based on the type of the termination signal.
241    if self.is_test_signal:
242      self._set_details(e.details)
243      self.extras = e.extras
244    else:
245      self._set_details(e)
246
247  def _set_details(self, content):
248    """Sets the `details` field.
249
250    Args:
251      content: the content to extract details from.
252    """
253    try:
254      self.details = str(content)
255    except UnicodeEncodeError:
256      # We should never hit this in Py3, But if this happens, record
257      # an encoded version of the content for users to handle.
258      logging.error('Unable to decode "%s" in Py3, encoding in utf-8.', content)
259      self.details = content.encode('utf-8')
260
261  def to_dict(self):
262    result = {}
263    result[TestResultEnums.RECORD_DETAILS] = self.details
264    result[TestResultEnums.RECORD_POSITION] = self.position
265    result[TestResultEnums.RECORD_STACKTRACE] = self.stacktrace
266    result[TestResultEnums.RECORD_EXTRAS] = copy.deepcopy(self.extras)
267    return result
268
269  def __deepcopy__(self, memo):
270    """Overrides deepcopy for the class.
271
272    If the exception object has a constructor that takes extra args, deep
273    copy won't work. So we need to have a custom logic for deepcopy.
274    """
275    try:
276      exception = copy.deepcopy(self.exception)
277    except TypeError:
278      # If the exception object cannot be copied, use the original
279      # exception object.
280      exception = self.exception
281    result = ExceptionRecord(exception, self.position)
282    result.stacktrace = self.stacktrace
283    result.details = self.details
284    result.extras = copy.deepcopy(self.extras)
285    result.position = self.position
286    return result
287
288
289class TestResultRecord:
290  """A record that holds the information of a single test.
291
292  The record object holds all information of a test, including all the
293  exceptions occurred during the test.
294
295  A test can terminate for two reasons:
296    1. the test function executes to the end and completes naturally.
297    2. the test is terminated by an exception, which we call
298     "termination signal".
299
300  The termination signal is treated differently. Its content are extracted
301  into first-tier attributes of the record object, like `details` and
302  `stacktrace`, for easy consumption.
303
304  Note the termination signal is not always an error, it can also be explicit
305  pass signal or abort/skip signals.
306
307  Attributes:
308    test_name: string, the name of the test.
309    begin_time: Epoch timestamp of when the test started.
310    end_time: Epoch timestamp of when the test ended.
311    uid: User-defined unique identifier of the test.
312    signature: string, unique identifier of a test record, the value is
313      generated by Mobly.
314    retry_parent: TestResultRecord, only set for retry iterations. This is the
315      test result record of the previous retry iteration. Parsers can use this
316      field to construct the chain of execution for each retried test.
317    termination_signal: ExceptionRecord, the main exception of the test.
318    extra_errors: OrderedDict, all exceptions occurred during the entire
319      test lifecycle. The order of occurrence is preserved.
320    result: TestResultEnum.TEAT_RESULT_*, PASS/FAIL/SKIP.
321  """
322
323  def __init__(self, t_name, t_class=None):
324    self.test_name = t_name
325    self.test_class = t_class
326    self.begin_time = None
327    self.end_time = None
328    self.uid = None
329    self.signature = None
330    self.retry_parent = None
331    self.termination_signal = None
332    self.extra_errors = collections.OrderedDict()
333    self.result = None
334
335  @property
336  def details(self):
337    """String description of the cause of the test's termination.
338
339    Note a passed test can have this as well due to the explicit pass
340    signal. If the test passed implicitly, this field would be None.
341    """
342    if self.termination_signal:
343      return self.termination_signal.details
344
345  @property
346  def stacktrace(self):
347    """The stacktrace string for the exception that terminated the test.
348    """
349    if self.termination_signal:
350      return self.termination_signal.stacktrace
351
352  @property
353  def extras(self):
354    """User defined extra information of the test result.
355
356    Must be serializable.
357    """
358    if self.termination_signal:
359      return self.termination_signal.extras
360
361  def test_begin(self):
362    """Call this when the test begins execution.
363
364    Sets the begin_time of this record.
365    """
366    self.begin_time = utils.get_current_epoch_time()
367    self.signature = '%s-%s' % (self.test_name, self.begin_time)
368
369  def _test_end(self, result, e):
370    """Marks the end of the test logic.
371
372    Args:
373      result: One of the TEST_RESULT enums in TestResultEnums.
374      e: A test termination signal (usually an exception object). It can
375        be any exception instance or of any subclass of
376        mobly.signals.TestSignal.
377    """
378    if self.begin_time is not None:
379      self.end_time = utils.get_current_epoch_time()
380    self.result = result
381    if e:
382      self.termination_signal = ExceptionRecord(e)
383
384  def update_record(self):
385    """Updates the content of a record.
386
387    Several display fields like "details" and "stacktrace" need to be
388    updated based on the content of the record object.
389
390    As the content of the record change, call this method to update all
391    the appropirate fields.
392    """
393    if self.extra_errors:
394      if self.result != TestResultEnums.TEST_RESULT_FAIL:
395        self.result = TestResultEnums.TEST_RESULT_ERROR
396    # If no termination signal is provided, use the first exception
397    # occurred as the termination signal.
398    if not self.termination_signal and self.extra_errors:
399      _, self.termination_signal = self.extra_errors.popitem(last=False)
400
401  def test_pass(self, e=None):
402    """To mark the test as passed in this record.
403
404    Args:
405      e: An instance of mobly.signals.TestPass.
406    """
407    self._test_end(TestResultEnums.TEST_RESULT_PASS, e)
408
409  def test_fail(self, e=None):
410    """To mark the test as failed in this record.
411
412    Only test_fail does instance check because we want 'assert xxx' to also
413    fail the test same way assert_true does.
414
415    Args:
416      e: An exception object. It can be an instance of AssertionError or
417        mobly.base_test.TestFailure.
418    """
419    self._test_end(TestResultEnums.TEST_RESULT_FAIL, e)
420
421  def test_skip(self, e=None):
422    """To mark the test as skipped in this record.
423
424    Args:
425      e: An instance of mobly.signals.TestSkip.
426    """
427    self._test_end(TestResultEnums.TEST_RESULT_SKIP, e)
428
429  def test_error(self, e=None):
430    """To mark the test as error in this record.
431
432    Args:
433      e: An exception object.
434    """
435    self._test_end(TestResultEnums.TEST_RESULT_ERROR, e)
436
437  def add_error(self, position, e):
438    """Add extra error happened during a test.
439
440    If the test has passed or skipped, this will mark the test result as
441    ERROR.
442
443    If an error is added the test record, the record's result is equivalent
444    to the case where an uncaught exception happened.
445
446    If the test record has not recorded any error, the newly added error
447    would be the main error of the test record. Otherwise the newly added
448    error is added to the record's extra errors.
449
450    Args:
451      position: string, where this error occurred, e.g. 'teardown_test'.
452      e: An exception or a `signals.ExceptionRecord` object.
453    """
454    if self.result != TestResultEnums.TEST_RESULT_FAIL:
455      self.result = TestResultEnums.TEST_RESULT_ERROR
456    if position in self.extra_errors:
457      raise Error('An exception is already recorded with position "%s",'
458                  ' cannot reuse.' % position)
459    if isinstance(e, ExceptionRecord):
460      self.extra_errors[position] = e
461    else:
462      self.extra_errors[position] = ExceptionRecord(e, position=position)
463
464  def __str__(self):
465    d = self.to_dict()
466    kv_pairs = ['%s = %s' % (k, v) for k, v in d.items()]
467    s = ', '.join(kv_pairs)
468    return s
469
470  def __repr__(self):
471    """This returns a short string representation of the test record."""
472    t = utils.epoch_to_human_time(self.begin_time)
473    return f'{t} {self.test_name} {self.result}'
474
475  def to_dict(self):
476    """Gets a dictionary representating the content of this class.
477
478    Returns:
479      A dictionary representating the content of this class.
480    """
481    d = {}
482    d[TestResultEnums.RECORD_NAME] = self.test_name
483    d[TestResultEnums.RECORD_CLASS] = self.test_class
484    d[TestResultEnums.RECORD_BEGIN_TIME] = self.begin_time
485    d[TestResultEnums.RECORD_END_TIME] = self.end_time
486    d[TestResultEnums.RECORD_RESULT] = self.result
487    d[TestResultEnums.RECORD_UID] = self.uid
488    d[TestResultEnums.RECORD_SIGNATURE] = self.signature
489    d[TestResultEnums.
490      RECORD_RETRY_PARENT] = self.retry_parent.signature if self.retry_parent else None
491    d[TestResultEnums.RECORD_EXTRAS] = self.extras
492    d[TestResultEnums.RECORD_DETAILS] = self.details
493    d[TestResultEnums.RECORD_EXTRA_ERRORS] = {
494        key: value.to_dict() for (key, value) in self.extra_errors.items()
495    }
496    d[TestResultEnums.RECORD_STACKTRACE] = self.stacktrace
497    return d
498
499
500class TestResult:
501  """A class that contains metrics of a test run.
502
503  This class is essentially a container of TestResultRecord objects.
504
505  Attributes:
506    requested: A list of strings, each is the name of a test requested
507      by user.
508    failed: A list of records for tests failed.
509    executed: A list of records for tests that were actually executed.
510    passed: A list of records for tests passed.
511    skipped: A list of records for tests skipped.
512    error: A list of records for tests with error result token.
513    controller_info: list of ControllerInfoRecord.
514  """
515
516  def __init__(self):
517    self.requested = []
518    self.failed = []
519    self.executed = []
520    self.passed = []
521    self.skipped = []
522    self.error = []
523    self.controller_info = []
524
525  def __add__(self, r):
526    """Overrides '+' operator for TestResult class.
527
528    The add operator merges two TestResult objects by concatenating all of
529    their lists together.
530
531    Args:
532      r: another instance of TestResult to be added
533
534    Returns:
535      A TestResult instance that's the sum of two TestResult instances.
536    """
537    if not isinstance(r, TestResult):
538      raise TypeError('Operand %s of type %s is not a TestResult.' %
539                      (r, type(r)))
540    sum_result = TestResult()
541    for name in sum_result.__dict__:
542      r_value = getattr(r, name)
543      l_value = getattr(self, name)
544      if isinstance(r_value, list):
545        setattr(sum_result, name, l_value + r_value)
546    return sum_result
547
548  def add_record(self, record):
549    """Adds a test record to test result.
550
551    A record is considered executed once it's added to the test result.
552
553    Adding the record finalizes the content of a record, so no change
554    should be made to the record afterwards.
555
556    Args:
557      record: A test record object to add.
558    """
559    record.update_record()
560    if record.result == TestResultEnums.TEST_RESULT_SKIP:
561      self.skipped.append(record)
562      return
563    self.executed.append(record)
564    if record.result == TestResultEnums.TEST_RESULT_FAIL:
565      self.failed.append(record)
566    elif record.result == TestResultEnums.TEST_RESULT_PASS:
567      self.passed.append(record)
568    else:
569      self.error.append(record)
570
571  def add_controller_info_record(self, controller_info_record):
572    """Adds a controller info record to results.
573
574    This can be called multiple times for each test class.
575
576    Args:
577      controller_info_record: ControllerInfoRecord object to be added to
578        the result.
579    """
580    self.controller_info.append(controller_info_record)
581
582  def add_class_error(self, test_record):
583    """Add a record to indicate a test class has failed before any test
584    could execute.
585
586    This is only called before any test is actually executed. So it only
587    adds an error entry that describes why the class failed to the tally
588    and does not affect the total number of tests requrested or exedcuted.
589
590    Args:
591      test_record: A TestResultRecord object for the test class.
592    """
593    test_record.update_record()
594    self.error.append(test_record)
595
596  def is_test_executed(self, test_name):
597    """Checks if a specific test has been executed.
598
599    Args:
600      test_name: string, the name of the test to check.
601
602    Returns:
603      True if the test has been executed according to the test result,
604      False otherwise.
605    """
606    for record in self.executed:
607      if record.test_name == test_name:
608        return True
609    return False
610
611  def _count_eventually_passing_retries(self):
612    """Counts the number of retry iterations that eventually passed.
613
614    If a test is retried and eventually passed, all the associated non-passing
615    iterations should not be considered when devising the final state of the
616    test run.
617
618    Returns:
619      Int, the number that should be subtracted from the result altering error
620      counts.
621    """
622    count = 0
623    for record in self.passed:
624      r = record
625      while r.retry_parent:
626        count += 1
627        r = r.retry_parent
628    return count
629
630  @property
631  def is_all_pass(self):
632    """True if no tests failed or threw errors, False otherwise."""
633    num_of_result_altering_errors = (len(self.failed) + len(self.error) -
634                                     self._count_eventually_passing_retries())
635    if num_of_result_altering_errors == 0:
636      return True
637    return False
638
639  def requested_test_names_dict(self):
640    """Gets the requested test names of a test run in a dict format.
641
642    Note a test can be requested multiple times, so there can be duplicated
643    values
644
645    Returns:
646      A dict with a key and the list of strings.
647    """
648    return {'Requested Tests': copy.deepcopy(self.requested)}
649
650  def summary_str(self):
651    """Gets a string that summarizes the stats of this test result.
652
653    The summary provides the counts of how many tests fall into each
654    category, like 'Passed', 'Failed' etc.
655
656    Format of the string is:
657      Requested <int>, Executed <int>, ...
658
659    Returns:
660      A summary string of this test result.
661    """
662    kv_pairs = ['%s %d' % (k, v) for k, v in self.summary_dict().items()]
663    # Sort the list so the order is the same every time.
664    msg = ', '.join(sorted(kv_pairs))
665    return msg
666
667  def summary_dict(self):
668    """Gets a dictionary that summarizes the stats of this test result.
669
670    The summary provides the counts of how many tests fall into each
671    category, like 'Passed', 'Failed' etc.
672
673    Returns:
674      A dictionary with the stats of this test result.
675    """
676    d = {}
677    d['Requested'] = len(self.requested)
678    d['Executed'] = len(self.executed)
679    d['Passed'] = len(self.passed)
680    d['Failed'] = len(self.failed)
681    d['Skipped'] = len(self.skipped)
682    d['Error'] = len(self.error)
683    return d
684