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