前言
Unittest是Python自带的自动化测试框架,提供了基本的控制结构和模型概念。
由于Unittest功能较为基础,因此在实际框架实战中往往需要对其功能进行扩充。
比如:
·生成HTML报告
·多线程并发(并且报告不混乱)
·自动重试出错用例
·为用例提供tags标签和level等级等,往往需要我们对Unittest框架进行二次开发和扩展,由于Unittest框架清晰的API,扩展和定制也非常方便。
unittest.TestResult类简介
TestResult类一般在TestRunner类中实例化,并穿梭于每个执行的测试套件和测试用例中用于记录结果。
TestResult对象常用的属性有:
·stream:用于输出测试信息的IO流,一般是终端或文本文件。
·descriptions:描述信息。
·verbosity:显示详细级别。
·buffer:默认为False,用例中的print信息立即输出,buffer为True时将用例中的print信息统一收集并集中输出。
·tb_locals: 在报错异常信息中显示用例中的局部变量(即tackback_locals)。
·failfast:默认为False, 用例失败后继续运行,为True时,任何一条用例失败时立即停止。
·_mirrorOutput:是否重定向输出流状态标志unittest.TestResult类提供了以下几种方法:
-运行开始/结束
startTestRun: 执行开始时调用,参考unittest.TextTestRunner中的run方法。
stopTestRun: 所有用例执行结束后调用
startTest:单个用例执行开始时调用,参考unittest.TestCase类中的run方法。
stopTest:单个用例执行结束后调用。
-注册用例结果
addSuccess:单个用例执行成功时调用,来注册结果,默认为空。
addFailure:用例失败时在stopTest前调用。
addError:用例异常时在stopTest前调用。
addSkip:用例跳过时在stopTest前调用。
addExpectedFailure:用例期望失败时在stopTest前调用。
addUnexpectedSuccess:用例非期望成功时在stopTest前调用。
-重定向和恢复系统输出流
_setupStdout:重定向输出流,默认self.buffer为True时生效
_restoreStdout:恢复系统输出流
用例失败Failure和用例异常Error的区别:
用例中的断言错误(期望结果和实际结果不一致)引发的AssertionError异常被视为用例失败,其他异常视为用例异常Error。
ExpectedFailure和UnexpectedSuccess: 期望失败指我们期望这条用例执行失败,即用例失败了才是符合预期的,而没有失败即UnexpectedSuccess,这是一种反向用例,如果失败了其实是通过,而成功了反而是失败。
TestResult类定制目标
1. 在result中增加整体的运行开始时间start_at,持续时间duration和每条用例的开始时间,执行时间
2. 存储用例中的print信息及异常信息,以供生成HTML使用
3. 为已知异常提供失败原因
4. 提供结构化和可序列化的summary和详情数据
5. 探测每个用例code,以为审视用例代码提供方便
6. 增加运行平台platform信息和运行时的环境变量信息
7. 将print信息改为使用log记录,增加日志时间,方便追溯。
8. 提供用例的更多的信息,如tags,level, id, 描述等信息。
实现步骤
测试结果summary格式规划
测试结果result类提供一个summary属性,格式如下(参考了httprunner的summary格式):
name: result结果名称 success: 整个测试结果是否成功 stat: # 结果统计信息 testsRun: 总运行数 successes: 成功数 failures: 失败数 errors: 异常数 skipped: 跳过的用例数 expectedFailures: 期望失败数 unexpectedSuccesses: 非期望成功数 time: start_at: 整个测试开始时间(时间戳) end_at: 增高测试结束时间(时间戳) duration: 整个测试执行耗时(秒) platform: platform: 执行平台信息 system: 执行操作系统信息 python_version: Python版本信息 # env: 环境变量信息(信息中可能包含账号等敏感信息) details: # 用例结果详情 - ... # 单个用例结果
单个用例结果格式规划
# 执行前可获取的信息 name: 用例名称或用例方法名 id: 用例完整路径(模块-类-用例方法名) decritpion: 用例描述(用例方法docstring第一行) doc: 用例方法完整docstring module_name: 用例模块名 class_name: 用例类名 class_id: 用例类路径(模块-类) class_doc: 用例类docstring描述 tags: 用例标签 level: 用例等级 code: 用例代码 # 执行后可获取的信息 time: start_at: 用例执行开始时间 end_at: 用例结束时间 duration: 用例执行持续时间 status: 用例执行状态success/fail/error/skipped/xfail/xpass output: 用例中的print输出信息 exc_info: 用例异常追溯信息 reason: 用例跳过,失败,出错的原因
读者也可以根据自己的需要添加其他额外的信息,如timeout用例超时时间配置,order用例执行顺序,images用例中的截图,link用例中的链接等信息。
以上的tags和level通过在用例方法的docstring中注入"tag:smoke"及"level:1"等样式为用例添加标签和等级,然后配合定制的loader用例加载器去收集指定标签或等级的用例,下节会详细讲解。
用例tags和level的实现
每个框架都会有自己约定格式,这里我采用在docstring注入特定格式描述的方式为用例添加tags和level信息,用例格式如下。
import unittest class TestDemo(unittest.TestCase): def test_a(self): """测试a tag:smoke tag:demo level:1 """ print('测试a')
对于每个用例对象,可以使用test._testMethodDoc来获取其完整的docstring字符串,然后通过正则匹配来匹配出用例的tags列表和level等级,实现方法如下。
import re TAG_PARTTEN = 'tag:(\w+)' LEVEL_PARTTEN = 'level:(\d+)' def get_case_tags(case: unittest.TestCase) -> list: """从用例方法的docstring中匹配出指定格式的tags""" case_tags = None case_doc = case._testMethodDoc if case_doc and 'tag' in case_doc: pattern = re.compile(TAG_PARTTEN) case_tags = re.findall(pattern, case_doc) return case_tags def get_case_level(case: unittest.TestCase): """从用例方法的docstring中匹配出指定格式的level""" case_doc = case._testMethodDoc case_level = None # todo 默认level if case_doc: pattern = re.compile(LEVEL_PARTTEN) levels = re.findall(pattern, case_doc) if levels: case_level = levels[0] try: case_level = int(case_level) except: raise ValueError(f'用例中level设置:{case_level} 应为整数格式') return case_level
根据测试方法对象获取用例代码
def inspect_code(test): test_method = getattr(test.__class__, test._testMethodName) try: code = inspect.getsource(test_method) except Exception as ex: log.exception(ex) code = '' return code
单个用例结果类的实现
由于单个用例结果信息较多,我们可以在整个TestResult类中使用一个嵌套字典格式存储,也可以单独定制一个用例结果类,参考如下。
class TestCaseResult(object): """用例测试结果""" def __init__(self, test: unittest.case.TestCase, name=None): self.test = test # 测试用例对象 self.name = name or test._testMethodName # 支持传入用例别名,unittest.TestCase自带属性方法 self.id = test.id() # 用例完整路径,unittest.TestCase自带方法 self.description = test.shortDescription() # 用例简要描述,unittest.TestCase自带方法 self.doc = test._testMethodDoc # 用例docstring,,unittest.TestCase自带属性方法 self.module_name = test.__module__ # 用例所在模块名 self.class_name = test.__class__.__name__ # 用例所在类名 self.class_id = f'{test.__module__}.{test.__class__.__name__}' # 用例所在类完整路径 self.class_doc = test.__class__.__doc__ # 用例所在类docstring描述 self.tags = get_case_tags(test) # 获取用例tags self.level = get_case_level(test) # 获取用例level等级 self.code = inspect_code(test) # 获取用例源代码 # 用例执后更新的信息 self.start_at = None # 用例开始时间 self.end_at = None # 用例结束时间 self.duration = None # 用例执行持续时间 self.status = None # 用例测试状态 self.output = None # 用例内的print信息 self.exc_info = None # 用例异常信息 self.reason = None # 跳过,失败,出错原因 @property def data(self): # 组合字典格式的用例结果数据 data = dict( name=self.name, id=self.id, description=self.description, status=self.status, tags=self.tags, level=self.level, time=dict( # 聚合时间信息 start_at=self.start_at, end_at=self.end_at, duration=self.duration ), class_name=self.class_name, class_doc=self.class_doc, module_name=self.module_name, code=self.code, output=self.output, exc_info=self.exc_info, reason=self.reason, ) return data
TestResult属性及初始化方法
根据上面对测试结果summary格式的规划,我们继承unittest.TestResult类来定制我们的测试结果类。
import unittest class TestResult(unittest.TestResult): """定制的测试结果类,补充用例运行时间等更多的执行信息""" def __init__(self,stream=None,descriptions=None,verbosity=None): super().__init__(stream, descriptions, verbosity) # 调用父类方法,继承父类的初始化属性,然后再进行扩充 # 对父类的默认熟悉做部分修改 self.testcase_results = [] # 所有用例测试结果对象(TestCaseResult对象)列表 self.successes = [] # 成功用例对象列表,万一用得着呢 self.verbosity = verbosity or 1 # 设置默认verbosity为1 self.buffer = True # 在本定制方法中强制使用self.buffer=True,缓存用例输出 self.name = None # 提供通过修改result对象的name属性为结果提供名称描述 self.start_at = None self.end_at = None self.duration = None # 由于继承的父类属性中存在failures、errors等属性(存放失败和异常的用例列表),此处加以区分 self.successes_count = 0 # 成功用例数 self.failures_count = 0 # 失败用例数 self.errors_count = 0 # 异常用例数 self.skipped_count = 0 # 跳过用例数 self.expectedFailures_count = 0 # 期望失败用例数 self.unexpectedSuccesses_count = 0 # 非期望成功用例数 self.know_exceptions = {} # 已知异常字典,用于通过异常名来映射失败原因,如 # self.know_exceptions = {'requests.exceptions.ConnectionError': '请求连接异常'} @property def summary(self): """组装结果概要, details分按运行顺序和按类组织两种结构""" data = dict( name=self.name, success=self.wasSuccessful(), # 用例是否成功,父类unittest.TestResult自带方法 stat=dict( testsRun=self.testsRun, successes=self.successes_count, failures=self.failures_count, errors=self.errors_count, skipped=self.skipped_count, expectedFailures=self.expectedFailures_count, unexpectedSuccesses=self.unexpectedSuccesses_count, ), time=dict( start_at=self.start_at, end_at=self.end_at, duration=self.duration ), platform=get_platform_info(), details=[item.data for item in self.testcase_results] # 每个测试用例结果对象转为其字典格式的数据 ) return data
测试开始和测试结束
使用log信息代替原来的print输出到stream流,这里使用的是笔者发布的开源包logz,安装方法为:
pip install logz
logz非常方便配置和使用,支持方便的配置,单例,DayRoting,准确的调用追溯以及log到Email等,详细使用方法可参考:https://github.com/hanzhichao/logz。
TestResult类中的verbosity属性用于控制输出信息的详细等级,unittest.TextTestResult分为0,1,2三级,作者这里也采用3级模式,逻辑稍有不同,这里设计的逻辑如下。
1、verbosity>1时:输出整个执行开始和结束信息,每个用例除自身print输出外,打印两条开始和结束两条日志,分别显示用例名称描述+执行时间和执行结果+持续时间。
2、verbosity为1时:不输出整体开始和结束信息,只每天用例输出用例方法名和执行状态一行日志。
3、verbosity为0时:不输出任何信息,包括错误信息。
以下为对父类执行开始和执行结束方法的重写。
import time from logz import log # 需要安装logz def time_to_string(timestamp: float) -> str: """时间戳转时间字符串,便于日志中更易读"" time_array = time.localtime(timestamp) time_str = time.strftime("%Y-%m-%d %H:%M:%S", time_array) return time_str class TestResut(unittest.TestResult): ... def startTestRun(self): """整个执行开始""" self.start_at = time.time() # 整个执行的开始时间 if self.verbosity > 1: self._log(f'===== 测试开始, 开始时间: {time_to_string(self.start_at)} =====') def stopTestRun(self): """整个执行结束""" self.end_at = time.time() # 整个执行的结束时间 self.duration = self.end_at - self.start_at # 整个执行的持续 self.success = self.wasSuccessful() # 整个执行是否成功 if self.verbosity > 1: self._log(f'===== 测试结束, 持续时间: {self.duration}秒 =====')
由于父类中的startTestRun和stopTestRun没有任何内容,此处不需要再调用父类的方法。
原始的unittest.TextTestRunner中对整个执行时间的统计是在result对象外的,此处集成到result对象中,已使result的结果信息更完整。
用例开始和用例结束
捕获用例输出信息,在用例中常常会有print信息或出错信息,这里面的信息是直接写到系统标准输出stdout和stderr中的。要捕获并记录这些信息的话,我们需要再执行用例的过程中(从startTest到stopTest)将系统stdout和stderr临时重定向到我们的io流变量中,然后通过get_value()获取其中的字符串。
可喜的是,父类unittest.TestResult中便提供了重定向和恢复输出的参考方法,我们稍微改动即可。
1. 重写恢复输出流方法
由于startTest父类中自动调用_setupOutput方法,并且强制self.buffer为True,因此会自动重定向信息流,无需重写。
这里去掉了对原始输出流的信息输出,改为return字符串,之后再使用log输出。
def _restoreStdout(self): """重写父类的_restoreStdout方法并返回output+error""" if self.buffer: output = error = '' if self._mirrorOutput: output = sys.stdout.getvalue() error = sys.stderr.getvalue() # 去掉了对原始输出流的信息输出 sys.stdout = self._original_stdout sys.stderr = self._original_stderr self._stdout_buffer.seek(0) self._stdout_buffer.truncate() self._stderr_buffer.seek(0) self._stderr_buffer.truncate() return output + error or None # 改为return字符串,之后再log输出
2. 用例开始和结束方法
def startTest(self, test: unittest.case.TestCase): """单个用例执行开始""" super().startTest(test) # 调用父类方法 test.result = TestCaseResult(test) # 实例化用例结果对象来记录用例结果,并绑定用例的result属性 self.testcase_results.append(test.result) # 另外添加到所有的结果列表一份 test.result.start_at = time.time() # 记录用例开始时间 if self.verbosity > 1: self._log(f'执行用例: {test.result.name}: {test.result.description}, 开始时间: {time_to_string(test.result.start_at)}') def stopTest(self, test: unittest.case.TestCase) -> None: """单个用例结束""" test.result.end_at = time.time() # 记录用例结束时间 test.result.duration = test.result.end_at - test.result.start_at # 记录用例持续时间 # 由于output要从_restoreStdout获取,手动加入父类恢复输出流的方法 test.result.output = self._restoreStdout() self._mirrorOutput = False # 是否重定向输出流标志
用例结果注册
def addSuccess(self, test): """重写父类方法, 单个用例成功时在stopTest前调用""" test.result.status = TestStatus.SUCCESS self.successes.append(test) self.successes_count += 1 super().addSuccess(test) @failfast def addFailure(self, test, err): """重写父类方法, 用例失败时在stopTest前调用""" test.result.status = TestStatus.FAIL test.result.exc_info = self._exc_info_to_string(err, test) test.result.reason = self._get_exc_msg(err) self.failures_count += 1 super().addFailure(test, err) @failfast def addError(self, test, err): """重写父类方法, 用例异常时在stopTest前调用""" test.result.status = TestStatus.ERROR test.result.exc_info = self._exc_info_to_string(err, test) test.result.reason = self._get_exc_msg(err) self.errors_count += 1 super().addError(test, err) def addSkip(self, test, reason): """重写父类方法, 用例跳过时在stopTest前调用""" test.result.status = TestStatus.SKIPPED test.result.reason = reason self.skipped_count += 1 super().addSkip(test, reason) def addExpectedFailure(self, test, err): """重写父类方法, 用例期望失败时在stopTest前调用""" test.result.status = TestStatus.XFAIL test.result.exc_info = self._exc_info_to_string(err, test) test.result.reason = self._get_exc_msg(err) self.expectedFailures_count += 1 super().addExpectedFailure(test, err) @failfast def addUnexpectedSuccess(self, test): """重写父类方法, 用例非期望成功时在stopTest前调用""" test.result.status = TestStatus.XPASS self.expectedFailures_count += 1 super().addUnexpectedSuccess(test)
测试本TestResult类方法
if __name__ == '__main__': import unittest class TestDemo(unittest.TestCase): def test_a(self): # 可以添加更多的用例进行测试 """测试a tag:smoke tag:demo level:1 """ print('测试a') suite = unittest.defaultTestLoader.loadTestsFromTestCase(TestDemo) runner = unittest.TextTestRunner(resultclass=TestResult) # 使用定制的TestResult类 result = runner.run(suite) print(result.summary) # 输出result的字典格式数据,建议使用pprint输出,需要安装pprint
注:由于和作者本人自己使用的TestResult类有所精简和改动,尚未进行更多的测试,如有问题欢迎留言指正。
其他函数和方法
1. 用例状态列
为了方便修改状态名称,(如改成中文),这里使用用例状态类。
class TestStatus(object): SUCCESS = 'success' FAIL = 'fail' ERROR = 'error' SKIPPED = 'skipped' XFAIL = 'xfail' XPASS = 'xpass'
2. 获取平台信息
import os def get_platform_info(): """获取执行平台信息""" return { "platform": platform.platform(), "system": platform.system(), "python_version": platform.python_version(), # "env": dict(os.environ), }
3. 从异常中提取异常信息方法
def _exc_info_to_string(self, err, test): """重写父类的转换异常方法, 去掉buffer的输出""" exctype, value, tb = err while tb and self._is_relevant_tb_level(tb): tb = tb.tb_next if exctype is test.failureException: # Skip assert*() traceback levels length = self._count_relevant_tb_levels(tb) else: length = None tb_e = traceback.TracebackException( exctype, value, tb, limit=length, capture_locals=self.tb_locals) msgLines = list(tb_e.format()) return ''.join(msgLines)
4. 从异常和已知异常中提取失败原因的方法
def _get_exc_msg(self, err): exctype, value, tb = err exc_msg = str(value) exc_full_path = f'{exctype.__module__}.{exctype.__name__}' if self.know_exceptions and isinstance(self.know_exceptions, dict): exc_msg = self.know_exceptions.get(exc_full_path, exc_msg) return exc_msg
作者: 韩志超
出处:https://www.cnblogs.com/superhin/p/13709161.html