#!/usr/bin/env python # Modified from https://github.com/agramian/custom-text-test-runner # The MIT License (MIT) # # Copyright (c) 2015 Abtin Gramian # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import unittest import os, sys, traceback import inspect import json import time import re import operator from unittest.runner import result from unittest.runner import registerResult from functools import reduce class TablePrinter(object): # Modified from https://github.com/agramian/table-printer, same license as above "Print a list of dicts as a table" def __init__(self, fmt, sep='', ul=None, tl=None, bl=None): """ @param fmt: list of tuple(heading, key, width) heading: str, column label key: dictionary key to value to print width: int, column width in chars @param sep: string, separation between columns @param ul: string, character to underline column label, or None for no underlining @param tl: string, character to draw as top line over table, or None @param bl: string, character to draw as bottom line under table, or None """ super(TablePrinter,self).__init__() fmt = [x + ('left',) if len(x) < 4 else x for x in fmt] self.fmt = str(sep).join('{lb}{0}:{align}{1}{rb}'.format(key, width, lb='{', rb='}', align='<' if alignment == 'left' else '>') for heading,key,width,alignment in fmt) self.head = {key:heading for heading,key,width,alignment in fmt} self.ul = {key:str(ul)*width for heading,key,width,alignment in fmt} if ul else None self.width = {key:width for heading,key,width,alignment in fmt} self.tl = {key:str(tl)*width for heading,key,width,alignment in fmt} if tl else None self.bl = {key:str(bl)*width for heading,key,width,alignment in fmt} if bl else None def row(self, data, separation_character=False): if separation_character: return self.fmt.format(**{ k:str(data.get(k,''))[:w] for k,w in self.width.items() }) else: data = { k:str(data.get(k,'')) if len(str(data.get(k,''))) <= w else '%s...' %str(data.get(k,''))[:(w-3)] for k,w in self.width.items() } return self.fmt.format(**data) def __call__(self, data_list, totals=None): _r = self.row res = [_r(data) for data in data_list] res.insert(0, _r(self.head)) if self.ul: res.insert(1, _r(self.ul, True)) if self.tl: res.insert(0, _r(self.tl, True)) if totals: if self.ul: res.insert(len(res), _r(self.ul, True)) res.insert(len(res), _r(totals)) if self.bl: res.insert(len(res), _r(self.bl, True)) return '\n'.join(res) def get_function_args(func_ref): try: return [p for p in inspect.getargspec(func_ref).args if p != 'self'] except: return None def store_class_fields(class_ref, args_passed): """ Store the passed in class fields in self """ params = get_function_args(class_ref.__init__) for p in params: setattr(class_ref, p, args_passed[p]) def sum_dict_key(d, key, cast_type=None): """ Sum together all values matching a key given a passed dict """ return reduce( (lambda x, y: x + y), [eval("%s(x['%s'])" %(cast_type, key)) if cast_type else x[key] for x in d] ) def case_name(name): """ Test case name decorator to override function name. """ def decorator(function): function.__dict__['test_case_name'] = name return function return decorator def skip_device(name): """ Decorator to mark a test to only run on certain devices Takes single device name or list of names as argument """ def decorator(function): name_list = name if type(name) == list else [name] function.__dict__['skip_device'] = name_list return function return decorator def _set_test_type(function, test_type): """ Test type setter """ if 'test_type' in function.__dict__: function.__dict__['test_type'].append(test_type) else: function.__dict__['test_type'] = [test_type] return function def smoke(function): """ Test decorator to mark test as smoke type """ return _set_test_type(function, 'smoke') def guide_discovery(function): """ Test decorator to mark test as guide_discovery type """ return _set_test_type(function, 'guide_discovery') def focus(function): """ Test decorator to mark test as focus type to all rspec style debugging of cases """ return _set_test_type(function, 'focus') class _WritelnDecorator(object): """Used to decorate file-like objects with a handy 'writeln' method""" def __init__(self,stream): self.stream = stream def __getattr__(self, attr): if attr in ('stream', '__getstate__'): raise AttributeError(attr) return getattr(self.stream,attr) def writeln(self, arg=None): if arg: self.write(arg) self.write('\n') # text-mode streams translate to \r\n if needed class CustomTextTestResult(result.TestResult): _num_formatting_chars = 150 _execution_time_significant_digits = 4 _pass_percentage_significant_digits = 2 def __init__(self, stream, descriptions, verbosity, results_file_path, result_screenshots_dir, show_previous_results, config, test_types): super(CustomTextTestResult, self).__init__(stream, descriptions, verbosity) store_class_fields(self, locals()) self.show_overall_results = verbosity > 0 self.show_test_info = verbosity > 1 self.show_individual_suite_results = verbosity > 2 self.show_errors = verbosity > 3 self.show_errors_detail = verbosity > 4 self.show_all = verbosity > 4 self.suite = None self.total_execution_time = 0 self.separator1 = "=" * CustomTextTestResult._num_formatting_chars self.separator2 = "-" * CustomTextTestResult._num_formatting_chars self.separator3 = "_" * CustomTextTestResult._num_formatting_chars self.separator4 = "*" * CustomTextTestResult._num_formatting_chars self.separator_failure = "!" * CustomTextTestResult._num_formatting_chars self.separator_pre_result = '.' * CustomTextTestResult._num_formatting_chars def getDescription(self, test): doc_first_line = test.shortDescription() if self.descriptions and doc_first_line: return '\n'.join((str(test), doc_first_line)) else: return str(test) def getSuiteDescription(self, test): doc = test.__class__.__doc__ return doc and doc.split("\n")[0].strip() or None def startTestRun(self): self.results = None self.previous_suite_runs = [] if os.path.isfile(self.results_file_path): with open(self.results_file_path, 'rb') as f: try: self.results = json.load(f) # recreated results dict with int keys self.results['suites'] = {int(k):v for (k,v) in list(self.results['suites'].items())} self.suite_map = {v['name']:int(k) for (k,v) in list(self.results['suites'].items())} self.previous_suite_runs = list(self.results['suites'].keys()) except: pass if not self.results: self.results = {'suites': {}, 'name': '', 'num_passed': 0, 'num_failed': 0, 'num_skipped': 0, 'num_expected_failures': 0, 'execution_time': None} self.suite_number = int(sorted(self.results['suites'].keys())[-1]) + 1 if len(self.results['suites']) else 0 self.case_number = 0 self.suite_map = {} def stopTestRun(self): # if no tests or some failure occurred execution time may not have been set try: self.results['suites'][self.suite_map[self.suite]]['execution_time'] = format(self.suite_execution_time, '.%sf' %CustomTextTestResult._execution_time_significant_digits) except: pass self.results['execution_time'] = format(self.total_execution_time, '.%sf' %CustomTextTestResult._execution_time_significant_digits) self.stream.writeln(self.separator3) with open(self.results_file_path, 'w') as f: json.dump(self.results, f) def startTest(self, test): suite_base_category = test.__class__.base_test_category if hasattr(test.__class__, 'base_test_category') else '' self.next_suite = os.path.join(suite_base_category, test.__class__.name if hasattr(test.__class__, 'name') else test.__class__.__name__) self.case = test._testMethodName super(CustomTextTestResult, self).startTest(test) if not self.suite or self.suite != self.next_suite: if self.suite: self.results['suites'][self.suite_map[self.suite]]['execution_time'] = format(self.suite_execution_time, '.%sf' %CustomTextTestResult._execution_time_significant_digits) self.suite_execution_time = 0 self.suite = self.next_suite if self.show_test_info: self.stream.writeln(self.separator1) self.stream.writeln("TEST SUITE: %s" %self.suite) self.stream.writeln("Description: %s" %self.getSuiteDescription(test)) try: name_override = getattr(test, test._testMethodName).__func__.__dict__['test_case_name'] except: name_override = None self.case = name_override if name_override else self.case if self.show_test_info: # self.stream.writeln(self.separator2) self.stream.write("CASE: %s" %self.case) if desc := test.shortDescription(): self.stream.write(" (Description: %s)" % desc) self.stream.write("... ") # self.stream.writeln(self.separator2) self.stream.flush() self.current_case_number = self.case_number if self.suite not in self.suite_map: self.suite_map[self.suite] = self.suite_number self.results['suites'][self.suite_number] = { 'name': self.suite, 'class': test.__class__.__name__, 'module': re.compile('.* \((.*)\)').match(str(test)).group(1), 'description': self.getSuiteDescription(test), 'cases': {}, 'used_case_names': {}, 'num_passed': 0, 'num_failed': 0, 'num_skipped': 0, 'num_expected_failures': 0, 'execution_time': None} self.suite_number += 1 self.num_cases = 0 self.num_passed = 0 self.num_failed = 0 self.num_skipped = 0 self.num_expected_failures = 0 self.results['suites'][self.suite_map[self.suite]]['cases'][self.case_number] = { 'name': self.case, 'method': test._testMethodName, 'result': None, 'description': test.shortDescription(), 'note': None, 'errors': None, 'failures': None, 'screenshots': [], 'new_version': 'No', 'execution_time': None} self.start_time = time.time() if self.test_types: if ('test_type' in getattr(test, test._testMethodName).__func__.__dict__ and set([s.lower() for s in self.test_types]) == set([s.lower() for s in getattr(test, test._testMethodName).__func__.__dict__['test_type']])): pass else: getattr(test, test._testMethodName).__func__.__dict__['__unittest_skip_why__'] = 'Test run specified to only run tests of type "%s"' %','.join(self.test_types) getattr(test, test._testMethodName).__func__.__dict__['__unittest_skip__'] = True if 'skip_device' in getattr(test, test._testMethodName).__func__.__dict__: for device in getattr(test, test._testMethodName).__func__.__dict__['skip_device']: if self.config and device.lower() in self.config['device_name'].lower(): getattr(test, test._testMethodName).__func__.__dict__['__unittest_skip_why__'] = 'Test is marked to be skipped on %s' %device getattr(test, test._testMethodName).__func__.__dict__['__unittest_skip__'] = True break def stopTest(self, test): self.end_time = time.time() self.execution_time = self.end_time - self.start_time self.suite_execution_time += self.execution_time self.total_execution_time += self.execution_time super(CustomTextTestResult, self).stopTest(test) self.num_cases += 1 self.results['suites'][self.suite_map[self.suite]]['num_passed'] = self.num_passed self.results['suites'][self.suite_map[self.suite]]['num_failed'] = self.num_failed self.results['suites'][self.suite_map[self.suite]]['num_skipped'] = self.num_skipped self.results['suites'][self.suite_map[self.suite]]['num_expected_failures'] = self.num_expected_failures self.results['suites'][self.suite_map[self.suite]]['cases'][self.current_case_number]['execution_time']= format(self.execution_time, '.%sf' %CustomTextTestResult._execution_time_significant_digits) self.results['num_passed'] += self.num_passed self.results['num_failed'] += self.num_failed self.results['num_skipped'] += self.num_skipped self.results['num_expected_failures'] += self.num_expected_failures self.case_number += 1 def print_error_string(self, err): error_string = ''.join(traceback.format_exception(err[0], err[1], err[2])) if self.show_errors: self.stream.writeln(self.separator_failure) self.stream.write(error_string) return error_string def addScreenshots(self, test): for root, dirs, files in os.walk(self.result_screenshots_dir): for file in files: self.results['suites'][self.suite_map[self.suite]]['cases'][self.current_case_number]['screenshots'].append(os.path.join(root, file)) def addSuccess(self, test): super(CustomTextTestResult, self).addSuccess(test) if self.show_test_info: # self.stream.writeln(self.separator_pre_result) self.stream.writeln("PASS") self.stream.flush() self.results['suites'][self.suite_map[self.suite]]['cases'][self.current_case_number]['result'] = 'passed' self.num_passed += 1 self.addScreenshots(test) def addError(self, test, err): error_string = self.print_error_string(err) super(CustomTextTestResult, self).addError(test, err) if self.show_test_info: # self.stream.writeln(self.separator_pre_result) self.stream.writeln("ERROR") self.stream.flush() self.results['suites'][self.suite_map[self.suite]]['cases'][self.current_case_number]['result'] = 'error' self.results['suites'][self.suite_map[self.suite]]['cases'][self.current_case_number]['errors'] = error_string self.num_failed += 1 self.addScreenshots(test) def addFailure(self, test, err): error_string = self.print_error_string(err) super(CustomTextTestResult, self).addFailure(test, err) if self.show_test_info: # self.stream.writeln(self.separator_pre_result) self.stream.writeln("FAIL") self.stream.flush() self.results['suites'][self.suite_map[self.suite]]['cases'][self.current_case_number]['result'] = 'failed' self.results['suites'][self.suite_map[self.suite]]['cases'][self.current_case_number]['failures'] = error_string self.num_failed += 1 self.addScreenshots(test) def addSkip(self, test, reason): super(CustomTextTestResult, self).addSkip(test, reason) if self.show_test_info: # self.stream.writeln(self.separator_pre_result) self.stream.writeln("SKIPPED {0!r}".format(reason)) self.stream.flush() self.results['suites'][self.suite_map[self.suite]]['cases'][self.current_case_number]['result'] = 'skipped' self.results['suites'][self.suite_map[self.suite]]['cases'][self.current_case_number]['note'] = reason self.num_skipped += 1 def addExpectedFailure(self, test, err): super(CustomTextTestResult, self).addExpectedFailure(test, err) if self.show_test_info: # self.stream.writeln(self.separator_pre_result) self.stream.writeln("EXPECTED FAILURE") self.stream.flush() self.results['suites'][self.suite_map[self.suite]]['cases'][self.current_case_number]['result'] = 'expected_failure' self.num_expected_failures += 1 self.addScreenshots(test) def addUnexpectedSuccess(self, test): super(CustomTextTestResult, self).addUnexpectedSuccess(test) if self.show_test_info: # self.stream.writeln(self.separator_pre_result) self.stream.writeln("UNEXPECTED SUCCESS") self.stream.flush() self.num_failed += 1 self.addScreenshots(test) def printOverallSuiteResults(self, r): self.stream.writeln() self.stream.writeln(self.separator4) self.stream.writeln('OVERALL SUITE RESULTS') fmt = [ ('SUITE', 'suite', 50, 'left'), ('CASES', 'cases', 15, 'right'), ('PASSED', 'passed', 15, 'right'), ('FAILED', 'failed', 15, 'right'), ('SKIPPED', 'skipped', 15, 'right'), ('%', 'percentage', 20, 'right'), ('TIME (s)', 'time', 20, 'right') ] data = [] for x in r: data.append({'suite': r[x]['name'], 'cases': r[x]['num_passed'] + r[x]['num_failed'], 'passed': r[x]['num_passed'], 'failed': r[x]['num_failed'], 'skipped': r[x]['num_skipped'], 'expected_failures': r[x]['num_expected_failures'], 'percentage': float(r[x]['num_passed'])/(r[x]['num_passed'] + r[x]['num_failed']) * 100 if (r[x]['num_passed'] + r[x]['num_failed']) > 0 else 0, 'time': r[x]['execution_time']}) total_suites_passed = len([x for x in data if not x['failed']]) total_suites_passed_percentage = format(float(total_suites_passed)/len(data) * 100, '.%sf' %CustomTextTestResult._pass_percentage_significant_digits) totals = {'suite': 'TOTALS %s/%s (%s%%) suites passed' %(total_suites_passed, len(data), total_suites_passed_percentage), 'cases': sum_dict_key(data, 'cases'), 'passed': sum_dict_key(data, 'passed'), 'failed': sum_dict_key(data, 'failed'), 'skipped': sum_dict_key(data, 'skipped'), 'percentage': sum_dict_key(data, 'percentage')/len(data), 'time': sum_dict_key(data, 'time', 'float')} for x in data: operator.setitem(x, 'percentage', format(x['percentage'], '.%sf' %CustomTextTestResult._pass_percentage_significant_digits)) totals['percentage'] = format(totals['percentage'], '.%sf' %CustomTextTestResult._pass_percentage_significant_digits) self.stream.writeln( TablePrinter(fmt, tl=self.separator1, ul=self.separator2, bl=self.separator3)(data, totals) ) self.stream.writeln() def printIndividualSuiteResults(self, r): self.stream.writeln() self.stream.writeln(self.separator4) self.stream.writeln('INDIVIDUAL SUITE RESULTS') fmt = [ ('CASE', 'case', 50, 'left'), ('DESCRIPTION', 'description', 50, 'right'), ('RESULT', 'result', 25, 'right'), ('TIME (s)', 'time', 25, 'right') ] for suite in r: self.stream.writeln(self.separator1) self.stream.write('{0: <50}'.format('SUITE: %s' %r[suite]['name'])) self.stream.writeln('{0: <100}'.format('DESCRIPTION: %s' %(r[suite]['description'] if not r[suite]['description'] or len(r[suite]['description']) <= (100 - len('DESCRIPTION: ')) else '%s...' %r[suite]['description'][:(97 - len('DESCRIPTION: '))]))) data = [] cases = r[suite]['cases'] for x in cases: data.append({'case': cases[x]['name'], 'description': cases[x]['description'], 'result': cases[x]['result'].upper() if cases[x]['result'] else cases[x]['result'], 'time': cases[x]['execution_time']}) self.stream.writeln( TablePrinter(fmt, tl=self.separator1, ul=self.separator2)(data) ) self.stream.writeln(self.separator3) self.stream.writeln() def printErrorsOverview(self, r): self.stream.writeln() self.stream.writeln(self.separator4) self.stream.writeln('FAILURES AND ERRORS OVERVIEW') fmt = [ ('SUITE', 'suite', 50, 'left'), ('CASE', 'case', 50, 'left'), ('RESULT', 'result', 50, 'right') ] data = [] for suite in r: cases = {k:v for (k,v) in list(r[suite]['cases'].items()) if v['failures'] or v['errors']} for x in cases: data.append({'suite': '%s%s' %(r[suite]['name'], ' (%s)' %r[suite]['module'] if r[suite]['class'] != r[suite]['name'] else ''), 'case': '%s%s' %(cases[x]['name'], ' (%s)' %cases[x]['method'] if cases[x]['name'] != cases[x]['method'] else ''), 'result': cases[x]['result'].upper()}) self.stream.writeln( TablePrinter(fmt, tl=self.separator1, ul=self.separator2)(data) ) self.stream.writeln(self.separator3) self.stream.writeln() def printErrorsDetail(self, r): self.stream.writeln() self.stream.writeln(self.separator4) self.stream.writeln('FAILURES AND ERRORS DETAIL') for suite in r: failures_and_errors = [k for (k,v) in list(r[suite]['cases'].items()) if v['failures'] or v['errors']] #print failures_and_errors suite_str = '%s%s' %(r[suite]['name'], ' (%s)' %r[suite]['module'] if r[suite]['class'] != r[suite]['name'] else '') for case in failures_and_errors: case_ref = r[suite]['cases'][case] case_str = '%s%s' %(case_ref['name'], ' (%s)' %case_ref['method'] if case_ref['name'] != case_ref['method'] else '') errors = case_ref['errors'] failures = case_ref['failures'] self.stream.writeln(self.separator1) if errors: self.stream.writeln('ERROR: %s [%s]' %(case_str, suite_str)) self.stream.writeln(self.separator2) self.stream.writeln(errors) if failures: self.stream.writeln('FAILURE: %s [%s]' %(case_str, suite_str)) self.stream.writeln(self.separator2) self.stream.writeln(failures) self.stream.writeln(self.separator3) self.stream.writeln() def printSkippedDetail(self, r): self.stream.writeln() self.stream.writeln(self.separator4) self.stream.writeln('SKIPPED DETAIL') fmt = [ ('SUITE', 'suite', 50, 'left'), ('CASE', 'case', 50, 'left'), ('REASON', 'reason', 50, 'right') ] data = [] for suite in r: cases = {k:v for (k,v) in list(r[suite]['cases'].items()) if v['result'] == 'skipped'} for x in cases: data.append({'suite': '%s%s' %(r[suite]['name'], ' (%s)' %r[suite]['module'] if r[suite]['class'] != r[suite]['name'] else ''), 'case': '%s%s' %(cases[x]['name'], ' (%s)' %cases[x]['method'] if cases[x]['name'] != cases[x]['method'] else ''), 'reason': cases[x]['note']}) self.stream.writeln( TablePrinter(fmt, tl=self.separator1, ul=self.separator2)(data) ) self.stream.writeln(self.separator3) self.stream.writeln() def returnCode(self): return not self.wasSuccessful() class CustomTextTestRunner(unittest.TextTestRunner): """A test runner class that displays results in textual form. It prints out the names of tests as they are run, errors as they occur, and a summary of the results at the end of the test run. """ def __init__(self, stream=sys.stderr, descriptions=True, verbosity=1, failfast=False, buffer=False, resultclass=CustomTextTestResult, results_file_path="results.json", result_screenshots_dir='', show_previous_results=False, test_name=None, test_description=None, config=None, test_types=None): store_class_fields(self, locals()) self.stream = _WritelnDecorator(stream) def _makeResult(self): return self.resultclass(self.stream, self.descriptions, self.verbosity, self.results_file_path, self.result_screenshots_dir, self.show_previous_results, self.config, self.test_types) def run(self, test): output = "" "Run the given test case or test suite." result = self._makeResult() registerResult(result) result.failfast = self.failfast result.buffer = self.buffer startTime = time.time() startTestRun = getattr(result, 'startTestRun', None) if startTestRun is not None: startTestRun() try: test(result) finally: stopTestRun = getattr(result, 'stopTestRun', None) if stopTestRun is not None: stopTestRun() stopTime = time.time() timeTaken = stopTime - startTime # filter results to output if result.show_previous_results: r = result.results['suites'] else: r = {k:v for (k,v) in list(result.results['suites'].items()) if k not in result.previous_suite_runs} # print results based on verbosity if result.show_all: result.printSkippedDetail(r) if result.show_errors_detail: result.printErrorsDetail(r) if result.show_individual_suite_results: result.printIndividualSuiteResults(r) if result.show_errors: result.printErrorsOverview(r) if result.show_overall_results: result.printOverallSuiteResults(r) run = result.testsRun self.stream.writeln("Ran %d test case%s in %.4fs" % (run, run != 1 and "s" or "", timeTaken)) self.stream.writeln() expectedFails = unexpectedSuccesses = skipped = 0 try: results = map(len, (result.expectedFailures, result.unexpectedSuccesses, result.skipped)) except AttributeError: pass else: expectedFails, unexpectedSuccesses, skipped = results infos = [] if not result.wasSuccessful(): self.stream.write("FAILED") failed, errored = map(len, (result.failures, result.errors)) if failed: infos.append("failures=%d" % failed) if errored: infos.append("errors=%d" % errored) else: self.stream.write("OK") if skipped: infos.append("skipped=%d" % skipped) if expectedFails: infos.append("expected failures=%d" % expectedFails) if unexpectedSuccesses: infos.append("unexpected successes=%d" % unexpectedSuccesses) if infos: self.stream.writeln(" (%s)" % (", ".join(infos),)) else: self.stream.write("\n") return result