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