Unittest二次开发实战-01-定制TestResult类

前言

Unittest是Python自带的自动化测试框架,提供了基本的控制结构和模型概念。
由于Unittest功能较为基础,因此在实际框架实战中往往需要对其功能进行扩充。
比如:

  1. 生成HTML报告
  2. 多线程并发(并且报告不混乱)
  3. 自动重试出错用例
  4. 为用例提供tags标签和level等级
    等,往往需要我们对Unittest框架进行二次开发和扩展,由于Unittest框架清晰的API,扩展和定制也非常方便。

Unittest各个模块的API课参考:Unittest官方文档翻译

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输出
  1. 用例开始和结束方法
    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'
  1. 获取平台信息
import os
def get_platform_info():
    """获取执行平台信息"""
    return {
        "platform": platform.platform(),
        "system": platform.system(),
        "python_version": platform.python_version(),
        # "env": dict(os.environ),
    }
  1. 从异常中提取异常信息方法
    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)
  1. 从异常和已知异常中提取失败原因的方法
    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

完整代码

# 文件名: reult.py
import inspect
import platform
import sys
import time
import traceback
import unittest
import io
from unittest.result import failfast
import re

from logz import log

print = log.info

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


class TestStatus(object):
    SUCCESS = 'success'
    FAIL = 'fail'
    ERROR = 'error'
    SKIPPED = 'skipped'
    XFAIL = 'xfail'
    XPASS = 'xpass'


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


def get_platform_info():
    """获取执行平台信息"""
    return {
        "platform": platform.platform(),
        "system": platform.system(),
        "python_version": platform.python_version(),
        # "env": dict(os.environ),  # 可能包含敏感信息
    }


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


class TestCaseResult(object):
    """用例测试结果"""

    def __init__(self, test: unittest.case.TestCase, name=None):
        self.test = test  # 确保为测试用例

        self.name = name or test._testMethodName
        self.id = test.id()
        self.description = test.shortDescription()
        self.doc = test._testMethodDoc

        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__

        self.tags = get_case_tags(test)
        self.level = get_case_level(test)
        self.code = inspect_code(test)

        self.start_at = None
        self.end_at = None
        self.duration = None

        self.status = None
        self.output = None
        self.exc_info = None
        self.reason = None  # 跳过,失败,出错原因 todo


    @property
    def data(self):
        data = dict(
            name=self.name,
            id=self.test.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


class TestResult(unittest.TestResult):
    """测试结果,补充整个过程的运行时间"""

    def __init__(self,
                stream=None,
                 descriptions=None,
                 verbosity=None,
                 ):

        super().__init__(stream, descriptions, verbosity)
        self.successes = []
        self.testcase_results = []  # 执行的用例结果列表
        self.verbosity = verbosity or 1
        self.buffer = True
        self.know_exceptions = None

        self.name = None
        self.start_at = None
        self.end_at = None
        self.duration = None
        self.successes_count = 0
        self.failures_count = 0
        self.errors_count = 0
        self.skipped_count = 0
        self.expectedFailures_count = 0
        self.unexpectedSuccesses_count = 0


    @property
    def summary(self):
        """组装结果概要, details分按运行顺序和按类组织两种结构"""
        data = dict(
            name=self.name,
            success=self.wasSuccessful(),
            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=[testcase_result.data for testcase_result in self.testcase_results]
        )
        return data

    def _setupStdout(self):
        if self.buffer:
            if self._stderr_buffer is None:
                self._stderr_buffer = io.StringIO()
                self._stdout_buffer = io.StringIO()
            sys.stdout = self._stdout_buffer
            sys.stderr = self._stderr_buffer

    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

    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

    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)

    def startTestRun(self):
        """整个执行开始"""
        self.start_at = time.time()
        if self.verbosity > 1:
            print(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:
            print(f'===== 测试结束, 持续时间: {self.duration}秒 =====')

    def startTest(self, test: unittest.case.TestCase):
        """单个用例执行开始"""
        test.result = TestCaseResult(test)
        self.testcase_results.append(test.result)

        test.result.start_at = time.time()
        super(TestResult, self).startTest(test)

        if self.verbosity > 1:
            print(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
        test.result.output = self._restoreStdout()
        self._mirrorOutput = False

        if self.verbosity > 1:
            print(f'结果: {test.result.status}, 持续时间: {test.result.duration}秒')
        elif self.verbosity > 0:
            print(f'{test.result.name} ...  {test.result.status}')

        if self.verbosity > 0:
            if test.result.output:
                print(f'{test.result.output.strip()}')

            if test.result.exc_info:
                log.exception(test.result.exc_info)

    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)


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)

    from pprint import pprint # 需要pip install pprint
    print(result.summary)

在命令行中运行python result.py结果如下:

2020-09-21 22:57:45,418 INFO 测试a
2020-09-21 22:57:45,418 INFO test_a ...  success
Ran 1 test in 0.006s

OK
{'details': [{'class_doc': None,
              'class_name': 'TestDemo',
              'code': '        def test_a(self):\n'
                      '            """测试a\n'
                      '            tag:smoke\n'
                      '            tag:demo\n'
                      '            level:1\n'
                      '            """\n'
                      "            print('测试a')\n",
              'description': '测试a',
              'exc_info': None,
              'id': '__main__.TestDemo.test_a',
              'level': 1,
              'module_name': '__main__',
              'name': 'test_a',
              'output': None,
              'reason': None,
              'status': 'success',
              'tags': ['smoke', 'demo'],
              'time': {'duration': 0.0001838207244873047,
                       'end_at': 1600700265.418684,
                       'start_at': 1600700265.4185002}}],
 'name': None,
 'platform': {'platform': 'Darwin-19.6.0-x86_64-i386-64bit',
              'python_version': '3.7.7',
              'system': 'Darwin'},
 'stat': {'errors': 0,
          'expectedFailures': 0,
          'failures': 0,
          'skipped': 0,
          'successes': 1,
          'testsRun': 1,
          'unexpectedSuccesses': 0},
 'success': True,
 'time': {'duration': 0.005582094192504883,
          'end_at': 1600700265.418763,
          'start_at': 1600700265.4131808}}

Todo

  1. 测试结果按测试类聚合 --- 已完成,带整合统一的格式
  2. 对带重试的测试结果进行格式整合
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容