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