• 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                       explicit_end=True,
160                       allow_unicode=True,
161                       indent=4)
162
163
164class TestResultEnums:
165  """Enums used for TestResultRecord class.
166
167  Includes the tokens to mark test result with, and the string names for each
168  field in TestResultRecord.
169  """
170
171  RECORD_NAME = 'Test Name'
172  RECORD_CLASS = 'Test Class'
173  RECORD_BEGIN_TIME = 'Begin Time'
174  RECORD_END_TIME = 'End Time'
175  RECORD_RESULT = 'Result'
176  RECORD_UID = 'UID'
177  RECORD_EXTRAS = 'Extras'
178  RECORD_EXTRA_ERRORS = 'Extra Errors'
179  RECORD_DETAILS = 'Details'
180  RECORD_TERMINATION_SIGNAL_TYPE = 'Termination Signal Type'
181  RECORD_STACKTRACE = 'Stacktrace'
182  RECORD_SIGNATURE = 'Signature'
183  RECORD_RETRY_PARENT = 'Retry Parent'
184  RECORD_POSITION = 'Position'
185  TEST_RESULT_PASS = 'PASS'
186  TEST_RESULT_FAIL = 'FAIL'
187  TEST_RESULT_SKIP = 'SKIP'
188  TEST_RESULT_ERROR = 'ERROR'
189
190
191class ControllerInfoRecord:
192  """A record representing the controller info in test results."""
193
194  KEY_TEST_CLASS = TestResultEnums.RECORD_CLASS
195  KEY_CONTROLLER_NAME = 'Controller Name'
196  KEY_CONTROLLER_INFO = 'Controller Info'
197  KEY_TIMESTAMP = 'Timestamp'
198
199  def __init__(self, test_class, controller_name, info):
200    self.test_class = test_class
201    self.controller_name = controller_name
202    self.controller_info = info
203    self.timestamp = time.time()
204
205  def to_dict(self):
206    result = {}
207    result[self.KEY_TEST_CLASS] = self.test_class
208    result[self.KEY_CONTROLLER_NAME] = self.controller_name
209    result[self.KEY_CONTROLLER_INFO] = self.controller_info
210    result[self.KEY_TIMESTAMP] = self.timestamp
211    return result
212
213  def __repr__(self):
214    return str(self.to_dict())
215
216
217class ExceptionRecord:
218  """A record representing exception objects in TestResultRecord.
219
220  Attributes:
221    exception: Exception object, the original Exception.
222    type: string, type name of the exception object.
223    stacktrace: string, stacktrace of the Exception.
224    extras: optional serializable, this corresponds to the
225      `TestSignal.extras` field.
226    position: string, an optional label specifying the position where the
227      Exception ocurred.
228  """
229
230  def __init__(self, e, position=None):
231    self.exception = e
232    self.type = type(e).__name__
233    self.stacktrace = None
234    self.extras = None
235    self.position = position
236    self.is_test_signal = isinstance(e, signals.TestSignal)
237    # Record stacktrace of the exception.
238    # This check cannot be based on try...except, which messes up
239    # `exc_info`.
240    exc_traceback = e.__traceback__
241    if exc_traceback:
242      self.stacktrace = ''.join(
243          traceback.format_exception(e.__class__, e, exc_traceback))
244    # Populate fields based on the type of the termination signal.
245    if self.is_test_signal:
246      self._set_details(e.details)
247      self.extras = e.extras
248    else:
249      self._set_details(e)
250
251  def _set_details(self, content):
252    """Sets the `details` field.
253
254    Args:
255      content: the content to extract details from.
256    """
257    try:
258      self.details = str(content)
259    except UnicodeEncodeError:
260      # We should never hit this in Py3, But if this happens, record
261      # an encoded version of the content for users to handle.
262      logging.error('Unable to decode "%s" in Py3, encoding in utf-8.', content)
263      self.details = content.encode('utf-8')
264
265  def to_dict(self):
266    result = {}
267    result[TestResultEnums.RECORD_DETAILS] = self.details
268    result[TestResultEnums.RECORD_POSITION] = self.position
269    result[TestResultEnums.RECORD_STACKTRACE] = self.stacktrace
270    result[TestResultEnums.RECORD_EXTRAS] = copy.deepcopy(self.extras)
271    return result
272
273  def __deepcopy__(self, memo):
274    """Overrides deepcopy for the class.
275
276    If the exception object has a constructor that takes extra args, deep
277    copy won't work. So we need to have a custom logic for deepcopy.
278    """
279    try:
280      exception = copy.deepcopy(self.exception)
281    except (TypeError, RecursionError):
282      # If the exception object cannot be copied, use the original
283      # exception object.
284      exception = self.exception
285    result = ExceptionRecord(exception, self.position)
286    result.stacktrace = self.stacktrace
287    result.details = self.details
288    result.extras = copy.deepcopy(self.extras)
289    result.position = self.position
290    return result
291
292
293class TestResultRecord:
294  """A record that holds the information of a single test.
295
296  The record object holds all information of a test, including all the
297  exceptions occurred during the test.
298
299  A test can terminate for two reasons:
300    1. the test function executes to the end and completes naturally.
301    2. the test is terminated by an exception, which we call
302     "termination signal".
303
304  The termination signal is treated differently. Its content are extracted
305  into first-tier attributes of the record object, like `details` and
306  `stacktrace`, for easy consumption.
307
308  Note the termination signal is not always an error, it can also be explicit
309  pass signal or abort/skip signals.
310
311  Attributes:
312    test_name: string, the name of the test.
313    begin_time: Epoch timestamp of when the test started.
314    end_time: Epoch timestamp of when the test ended.
315    uid: User-defined unique identifier of the test.
316    signature: string, unique identifier of a test record, the value is
317      generated by Mobly.
318    retry_parent: TestResultRecord, only set for retry iterations. This is the
319      test result record of the previous retry iteration. Parsers can use this
320      field to construct the chain of execution for each retried test.
321    termination_signal: ExceptionRecord, the main exception of the test.
322    extra_errors: OrderedDict, all exceptions occurred during the entire
323      test lifecycle. The order of occurrence is preserved.
324    result: TestResultEnum.TEAT_RESULT_*, PASS/FAIL/SKIP.
325  """
326
327  def __init__(self, t_name, t_class=None):
328    self.test_name = t_name
329    self.test_class = t_class
330    self.begin_time = None
331    self.end_time = None
332    self.uid = None
333    self.signature = None
334    self.retry_parent = None
335    self.termination_signal = None
336    self.extra_errors = collections.OrderedDict()
337    self.result = None
338
339  @property
340  def details(self):
341    """String description of the cause of the test's termination.
342
343    Note a passed test can have this as well due to the explicit pass
344    signal. If the test passed implicitly, this field would be None.
345    """
346    if self.termination_signal:
347      return self.termination_signal.details
348
349  @property
350  def termination_signal_type(self):
351    """Type name of the signal that caused the test's termination.
352
353    Note a passed test can have this as well due to the explicit pass
354    signal. If the test passed implicitly, this field would be None.
355    """
356    if self.termination_signal:
357      return self.termination_signal.type
358
359  @property
360  def stacktrace(self):
361    """The stacktrace string for the exception that terminated the test.
362    """
363    if self.termination_signal:
364      return self.termination_signal.stacktrace
365
366  @property
367  def extras(self):
368    """User defined extra information of the test result.
369
370    Must be serializable.
371    """
372    if self.termination_signal:
373      return self.termination_signal.extras
374
375  def test_begin(self):
376    """Call this when the test begins execution.
377
378    Sets the begin_time of this record.
379    """
380    self.begin_time = utils.get_current_epoch_time()
381    self.signature = '%s-%s' % (self.test_name, self.begin_time)
382
383  def _test_end(self, result, e):
384    """Marks the end of the test logic.
385
386    Args:
387      result: One of the TEST_RESULT enums in TestResultEnums.
388      e: A test termination signal (usually an exception object). It can
389        be any exception instance or of any subclass of
390        mobly.signals.TestSignal.
391    """
392    if self.begin_time is not None:
393      self.end_time = utils.get_current_epoch_time()
394    self.result = result
395    if e:
396      self.termination_signal = ExceptionRecord(e)
397
398  def update_record(self):
399    """Updates the content of a record.
400
401    Several display fields like "details" and "stacktrace" need to be
402    updated based on the content of the record object.
403
404    As the content of the record change, call this method to update all
405    the appropirate fields.
406    """
407    if self.extra_errors:
408      if self.result != TestResultEnums.TEST_RESULT_FAIL:
409        self.result = TestResultEnums.TEST_RESULT_ERROR
410    # If no termination signal is provided, use the first exception
411    # occurred as the termination signal.
412    if not self.termination_signal and self.extra_errors:
413      _, self.termination_signal = self.extra_errors.popitem(last=False)
414
415  def test_pass(self, e=None):
416    """To mark the test as passed in this record.
417
418    Args:
419      e: An instance of mobly.signals.TestPass.
420    """
421    self._test_end(TestResultEnums.TEST_RESULT_PASS, e)
422
423  def test_fail(self, e=None):
424    """To mark the test as failed in this record.
425
426    Only test_fail does instance check because we want 'assert xxx' to also
427    fail the test same way assert_true does.
428
429    Args:
430      e: An exception object. It can be an instance of AssertionError or
431        mobly.base_test.TestFailure.
432    """
433    self._test_end(TestResultEnums.TEST_RESULT_FAIL, e)
434
435  def test_skip(self, e=None):
436    """To mark the test as skipped in this record.
437
438    Args:
439      e: An instance of mobly.signals.TestSkip.
440    """
441    self._test_end(TestResultEnums.TEST_RESULT_SKIP, e)
442
443  def test_error(self, e=None):
444    """To mark the test as error in this record.
445
446    Args:
447      e: An exception object.
448    """
449    self._test_end(TestResultEnums.TEST_RESULT_ERROR, e)
450
451  def add_error(self, position, e):
452    """Add extra error happened during a test.
453
454    If the test has passed or skipped, this will mark the test result as
455    ERROR.
456
457    If an error is added the test record, the record's result is equivalent
458    to the case where an uncaught exception happened.
459
460    If the test record has not recorded any error, the newly added error
461    would be the main error of the test record. Otherwise the newly added
462    error is added to the record's extra errors.
463
464    Args:
465      position: string, where this error occurred, e.g. 'teardown_test'.
466      e: An exception or a `signals.ExceptionRecord` object.
467    """
468    if self.result != TestResultEnums.TEST_RESULT_FAIL:
469      self.result = TestResultEnums.TEST_RESULT_ERROR
470    if position in self.extra_errors:
471      raise Error('An exception is already recorded with position "%s",'
472                  ' cannot reuse.' % position)
473    if isinstance(e, ExceptionRecord):
474      self.extra_errors[position] = e
475    else:
476      self.extra_errors[position] = ExceptionRecord(e, position=position)
477
478  def __str__(self):
479    d = self.to_dict()
480    kv_pairs = ['%s = %s' % (k, v) for k, v in d.items()]
481    s = ', '.join(kv_pairs)
482    return s
483
484  def __repr__(self):
485    """This returns a short string representation of the test record."""
486    t = utils.epoch_to_human_time(self.begin_time)
487    return f'{t} {self.test_name} {self.result}'
488
489  def to_dict(self):
490    """Gets a dictionary representating the content of this class.
491
492    Returns:
493      A dictionary representating the content of this class.
494    """
495    d = {}
496    d[TestResultEnums.RECORD_NAME] = self.test_name
497    d[TestResultEnums.RECORD_CLASS] = self.test_class
498    d[TestResultEnums.RECORD_BEGIN_TIME] = self.begin_time
499    d[TestResultEnums.RECORD_END_TIME] = self.end_time
500    d[TestResultEnums.RECORD_RESULT] = self.result
501    d[TestResultEnums.RECORD_UID] = self.uid
502    d[TestResultEnums.RECORD_SIGNATURE] = self.signature
503    d[TestResultEnums.
504      RECORD_RETRY_PARENT] = self.retry_parent.signature if self.retry_parent else None
505    d[TestResultEnums.RECORD_EXTRAS] = self.extras
506    d[TestResultEnums.RECORD_DETAILS] = self.details
507    d[TestResultEnums.
508      RECORD_TERMINATION_SIGNAL_TYPE] = self.termination_signal_type
509    d[TestResultEnums.RECORD_EXTRA_ERRORS] = {
510        key: value.to_dict() for (key, value) in self.extra_errors.items()
511    }
512    d[TestResultEnums.RECORD_STACKTRACE] = self.stacktrace
513    return d
514
515
516class TestResult:
517  """A class that contains metrics of a test run.
518
519  This class is essentially a container of TestResultRecord objects.
520
521  Attributes:
522    requested: A list of strings, each is the name of a test requested
523      by user.
524    failed: A list of records for tests failed.
525    executed: A list of records for tests that were actually executed.
526    passed: A list of records for tests passed.
527    skipped: A list of records for tests skipped.
528    error: A list of records for tests with error result token.
529    controller_info: list of ControllerInfoRecord.
530  """
531
532  def __init__(self):
533    self.requested = []
534    self.failed = []
535    self.executed = []
536    self.passed = []
537    self.skipped = []
538    self.error = []
539    self.controller_info = []
540
541  def __add__(self, r):
542    """Overrides '+' operator for TestResult class.
543
544    The add operator merges two TestResult objects by concatenating all of
545    their lists together.
546
547    Args:
548      r: another instance of TestResult to be added
549
550    Returns:
551      A TestResult instance that's the sum of two TestResult instances.
552    """
553    if not isinstance(r, TestResult):
554      raise TypeError('Operand %s of type %s is not a TestResult.' %
555                      (r, type(r)))
556    sum_result = TestResult()
557    for name in sum_result.__dict__:
558      r_value = getattr(r, name)
559      l_value = getattr(self, name)
560      if isinstance(r_value, list):
561        setattr(sum_result, name, l_value + r_value)
562    return sum_result
563
564  def add_record(self, record):
565    """Adds a test record to test result.
566
567    A record is considered executed once it's added to the test result.
568
569    Adding the record finalizes the content of a record, so no change
570    should be made to the record afterwards.
571
572    Args:
573      record: A test record object to add.
574    """
575    record.update_record()
576    if record.result == TestResultEnums.TEST_RESULT_SKIP:
577      self.skipped.append(record)
578      return
579    self.executed.append(record)
580    if record.result == TestResultEnums.TEST_RESULT_FAIL:
581      self.failed.append(record)
582    elif record.result == TestResultEnums.TEST_RESULT_PASS:
583      self.passed.append(record)
584    else:
585      self.error.append(record)
586
587  def add_controller_info_record(self, controller_info_record):
588    """Adds a controller info record to results.
589
590    This can be called multiple times for each test class.
591
592    Args:
593      controller_info_record: ControllerInfoRecord object to be added to
594        the result.
595    """
596    self.controller_info.append(controller_info_record)
597
598  def add_class_error(self, test_record):
599    """Add a record to indicate a test class has failed before any test
600    could execute.
601
602    This is only called before any test is actually executed. So it only
603    adds an error entry that describes why the class failed to the tally
604    and does not affect the total number of tests requrested or exedcuted.
605
606    Args:
607      test_record: A TestResultRecord object for the test class.
608    """
609    test_record.update_record()
610    self.error.append(test_record)
611
612  def is_test_executed(self, test_name):
613    """Checks if a specific test has been executed.
614
615    Args:
616      test_name: string, the name of the test to check.
617
618    Returns:
619      True if the test has been executed according to the test result,
620      False otherwise.
621    """
622    for record in self.executed:
623      if record.test_name == test_name:
624        return True
625    return False
626
627  def _count_eventually_passing_retries(self):
628    """Counts the number of retry iterations that eventually passed.
629
630    If a test is retried and eventually passed, all the associated non-passing
631    iterations should not be considered when devising the final state of the
632    test run.
633
634    Returns:
635      Int, the number that should be subtracted from the result altering error
636      counts.
637    """
638    count = 0
639    for record in self.passed:
640      r = record
641      while r.retry_parent:
642        count += 1
643        r = r.retry_parent
644    return count
645
646  @property
647  def is_all_pass(self):
648    """True if no tests failed or threw errors, False otherwise."""
649    num_of_result_altering_errors = (len(self.failed) + len(self.error) -
650                                     self._count_eventually_passing_retries())
651    if num_of_result_altering_errors == 0:
652      return True
653    return False
654
655  def requested_test_names_dict(self):
656    """Gets the requested test names of a test run in a dict format.
657
658    Note a test can be requested multiple times, so there can be duplicated
659    values
660
661    Returns:
662      A dict with a key and the list of strings.
663    """
664    return {'Requested Tests': copy.deepcopy(self.requested)}
665
666  def summary_str(self):
667    """Gets a string that summarizes the stats of this test result.
668
669    The summary provides the counts of how many tests fall into each
670    category, like 'Passed', 'Failed' etc.
671
672    Format of the string is:
673      Requested <int>, Executed <int>, ...
674
675    Returns:
676      A summary string of this test result.
677    """
678    kv_pairs = ['%s %d' % (k, v) for k, v in self.summary_dict().items()]
679    # Sort the list so the order is the same every time.
680    msg = ', '.join(sorted(kv_pairs))
681    return msg
682
683  def summary_dict(self):
684    """Gets a dictionary that summarizes the stats of this test result.
685
686    The summary provides the counts of how many tests fall into each
687    category, like 'Passed', 'Failed' etc.
688
689    Returns:
690      A dictionary with the stats of this test result.
691    """
692    d = {}
693    d['Requested'] = len(self.requested)
694    d['Executed'] = len(self.executed)
695    d['Passed'] = len(self.passed)
696    d['Failed'] = len(self.failed)
697    d['Skipped'] = len(self.skipped)
698    d['Error'] = len(self.error)
699    return d
700