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