Unittest二次開發(fā)實(shí)戰(zhàn)-01-定制TestResult類

前言

Unittest是Python自帶的自動化測試框架舆绎,提供了基本的控制結(jié)構(gòu)和模型概念。
由于Unittest功能較為基礎(chǔ)们颜,因此在實(shí)際框架實(shí)戰(zhàn)中往往需要對其功能進(jìn)行擴(kuò)充吕朵。
比如:

  1. 生成HTML報(bào)告
  2. 多線程并發(fā)(并且報(bào)告不混亂)
  3. 自動重試出錯用例
  4. 為用例提供tags標(biāo)簽和level等級
    等,往往需要我們對Unittest框架進(jìn)行二次開發(fā)和擴(kuò)展窥突,由于Unittest框架清晰的API边锁,擴(kuò)展和定制也非常方便。

Unittest各個(gè)模塊的API課參考:Unittest官方文檔翻譯

unittest.TestResult類簡介

TestResult類一般在TestRunner類中實(shí)例化波岛,并穿梭于每個(gè)執(zhí)行的測試套件和測試用例中用于記錄結(jié)果茅坛。
TestResult對象常用的屬性有:

  • stream:用于輸出測試信息的IO流,一般是終端或文本文件则拷。
  • descriptions:描述信息贡蓖。
  • verbosity:顯示詳細(xì)級別。
  • buffer:默認(rèn)為False煌茬,用例中的print信息立即輸出斥铺,buffer為True時(shí)將用例中的print信息統(tǒng)一收集并集中輸出。
  • tb_locals: 在報(bào)錯異常信息中顯示用例中的局部變量(即tackback_locals)坛善。
  • failfast:默認(rèn)為False, 用例失敗后繼續(xù)運(yùn)行晾蜘,為True時(shí),任何一條用例失敗時(shí)立即停止眠屎。
  • _mirrorOutput:是否重定向輸出流狀態(tài)標(biāo)志

unittest.TestResult類提供了以下幾種方法:

  • 運(yùn)行開始/結(jié)束
    • startTestRun: 執(zhí)行開始時(shí)調(diào)用剔交,參考unittest.TextTestRunner中的run方法。
    • stopTestRun: 所有用例執(zhí)行結(jié)束后調(diào)用
    • startTest:單個(gè)用例執(zhí)行開始時(shí)調(diào)用改衩,參考unittest.TestCase類中的run方法岖常。
    • stopTest:單個(gè)用例執(zhí)行結(jié)束后調(diào)用。
  • 注冊用例結(jié)果
    • addSuccess:單個(gè)用例執(zhí)行成功時(shí)調(diào)用葫督,來注冊結(jié)果竭鞍,默認(rèn)為空板惑。
    • addFailure:用例失敗時(shí)在stopTest前調(diào)用。
    • addError:用例異常時(shí)在stopTest前調(diào)用偎快。
    • addSkip:用例跳過時(shí)在stopTest前調(diào)用冯乘。
    • addExpectedFailure:用例期望失敗時(shí)在stopTest前調(diào)用。
    • addUnexpectedSuccess:用例非期望成功時(shí)在stopTest前調(diào)用晒夹。
  • 重定向和恢復(fù)系統(tǒng)輸出流
    • _setupStdout:重定向輸出流裆馒,默認(rèn)self.buffer為True時(shí)生效
    • _restoreStdout:恢復(fù)系統(tǒng)輸出流

用例失敗Failure和用例異常Error的區(qū)別:
用例中的斷言錯誤(期望結(jié)果和實(shí)際結(jié)果不一致)引發(fā)的AssertionError異常被視為用例失敗,其他異常視為用例異常Error惋戏。

ExpectedFailure和UnexpectedSuccess: 期望失敗指我們期望這條用例執(zhí)行失敗领追,即用例失敗了才是符合預(yù)期的他膳,而沒有失敗即UnexpectedSuccess响逢,這是一種反向用例,如果失敗了其實(shí)是通過棕孙,而成功了反而是失敗舔亭。

TestResult類定制目標(biāo)

  1. 在result中增加整體的運(yùn)行開始時(shí)間start_at,持續(xù)時(shí)間duration和每條用例的開始時(shí)間蟀俊,執(zhí)行時(shí)間
  2. 存儲用例中的print信息及異常信息钦铺,以供生成HTML使用
  3. 為已知異常提供失敗原因
  4. 提供結(jié)構(gòu)化和可序列化的summary和詳情數(shù)據(jù)
  5. 探測每個(gè)用例code,以為審視用例代碼提供方便
  6. 增加運(yùn)行平臺platform信息和運(yùn)行時(shí)的環(huán)境變量信息
  7. 將print信息改為使用log記錄肢预,增加日志時(shí)間矛洞,方便追溯。
  8. 提供用例的更多的信息烫映,如tags沼本,level, id锭沟, 描述等信息抽兆。

實(shí)現(xiàn)步驟

測試結(jié)果summary格式規(guī)劃

測試結(jié)果result類提供一個(gè)summary屬性,格式如下(參考了httprunner的summary格式):

name: result結(jié)果名稱
success: 整個(gè)測試結(jié)果是否成功
stat: # 結(jié)果統(tǒng)計(jì)信息
  testsRun: 總運(yùn)行數(shù)
  successes: 成功數(shù)
  failures: 失敗數(shù)
  errors: 異常數(shù)
  skipped: 跳過的用例數(shù)
  expectedFailures: 期望失敗數(shù)
  unexpectedSuccesses: 非期望成功數(shù)
time:
  start_at: 整個(gè)測試開始時(shí)間(時(shí)間戳)
  end_at: 增高測試結(jié)束時(shí)間(時(shí)間戳)
  duration: 整個(gè)測試執(zhí)行耗時(shí)(秒)
platform:
  platform: 執(zhí)行平臺信息
  system: 執(zhí)行操作系統(tǒng)信息
  python_version: Python版本信息
  # env: 環(huán)境變量信息(信息中可能包含賬號等敏感信息)
details:  # 用例結(jié)果詳情
  - ... # 單個(gè)用例結(jié)果

單個(gè)用例結(jié)果格式規(guī)劃

# 執(zhí)行前可獲取的信息
name: 用例名稱或用例方法名
id: 用例完整路徑(模塊-類-用例方法名)
decritpion: 用例描述(用例方法docstring第一行)
doc: 用例方法完整docstring
module_name: 用例模塊名
class_name: 用例類名
class_id: 用例類路徑(模塊-類)
class_doc: 用例類docstring描述
tags: 用例標(biāo)簽
level: 用例等級
code: 用例代碼
# 執(zhí)行后可獲取的信息
time:
  start_at: 用例執(zhí)行開始時(shí)間
  end_at: 用例結(jié)束時(shí)間
  duration: 用例執(zhí)行持續(xù)時(shí)間
status: 用例執(zhí)行狀態(tài)success/fail/error/skipped/xfail/xpass
output: 用例中的print輸出信息
exc_info: 用例異常追溯信息
reason: 用例跳過,失敗,出錯的原因

讀者也可以根據(jù)自己的需要添加其他額外的信息族淮,如timeout用例超時(shí)時(shí)間配置辫红,order用例執(zhí)行順序,images用例中的截圖祝辣,link用例中的鏈接等信息贴妻。

以上的tags和level通過在用例方法的docstring中注入"tag:smoke"及"level:1"等樣式為用例添加標(biāo)簽和等級湃崩,然后配合定制的loader用例加載器去收集指定標(biāo)簽或等級的用例禽绪,下節(jié)會詳細(xì)講解砖茸。

用例tags和level的實(shí)現(xiàn)

每個(gè)框架都會有自己約定格式碎连,這里我采用在docstring注入特定格式描述的方式為用例添加tags和level信息孩灯,用例格式如下。

import unittest

class TestDemo(unittest.TestCase):
    def test_a(self):
        """測試a
        tag:smoke
        tag:demo
        level:1
        """
        print('測試a')

對于每個(gè)用例對象语泽,可以使用test._testMethodDoc來獲取其完整的docstring字符串畏陕,然后通過正則匹配來匹配出用例的tags列表和level等級,實(shí)現(xiàn)方法如下底循。

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 默認(rèn)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'用例中l(wèi)evel設(shè)置:{case_level} 應(yīng)為整數(shù)格式')
    return case_level

根據(jù)測試方法對象獲取用例代碼

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

單個(gè)用例結(jié)果類的實(shí)現(xiàn)

由于單個(gè)用例結(jié)果信息較多巢株,我們可以在整個(gè)TestResult類中使用一個(gè)嵌套字典格式存儲,也可以單獨(dú)定制一個(gè)用例結(jié)果類熙涤,參考如下阁苞。

class TestCaseResult(object):
    """用例測試結(jié)果"""

    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)   # 獲取用例源代碼

        # 用例執(zhí)后更新的信息
        self.start_at = None    # 用例開始時(shí)間
        self.end_at = None  # 用例結(jié)束時(shí)間
        self.duration = None  # 用例執(zhí)行持續(xù)時(shí)間

        self.status = None  # 用例測試狀態(tài)
        self.output = None  # 用例內(nèi)的print信息
        self.exc_info = None  # 用例異常信息
        self.reason = None  # 跳過,失敗,出錯原因

    @property
    def data(self):  # 組合字典格式的用例結(jié)果數(shù)據(jù)
        data = dict(
            name=self.name,
            id=self.id,
            description=self.description,
            status=self.status,
            tags=self.tags,
            level=self.level,
            time=dict(  # 聚合時(shí)間信息
                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屬性及初始化方法

根據(jù)上面對測試結(jié)果summary格式的規(guī)劃骚灸,我們繼承unittest.TestResult類來定制我們的測試結(jié)果類

import unittest

class TestResult(unittest.TestResult):
    """定制的測試結(jié)果類,補(bǔ)充用例運(yùn)行時(shí)間等更多的執(zhí)行信息"""
    def __init__(self,stream=None,descriptions=None,verbosity=None):
        super().__init__(stream, descriptions, verbosity)  # 調(diào)用父類方法,繼承父類的初始化屬性慌植,然后再進(jìn)行擴(kuò)充
         # 對父類的默認(rèn)熟悉做部分修改
         self.testcase_results = []  # 所有用例測試結(jié)果對象(TestCaseResult對象)列表
         self.successes = []  # 成功用例對象列表甚牲,萬一用得著呢
         self.verbosity = verbosity or 1  # 設(shè)置默認(rèn)verbosity為1
         self.buffer = True  # 在本定制方法中強(qiáng)制使用self.buffer=True,緩存用例輸出
        
        self.name = None  # 提供通過修改result對象的name屬性為結(jié)果提供名稱描述 
        self.start_at = None
        self.end_at = None
        self.duration = None
        
        # 由于繼承的父類屬性中存在failures蝶柿、errors等屬性(存放失敗和異常的用例列表)丈钙,此處加以區(qū)分
        self.successes_count = 0  # 成功用例數(shù)
        self.failures_count = 0  # 失敗用例數(shù)
        self.errors_count = 0  # 異常用例數(shù)
        self.skipped_count = 0  # 跳過用例數(shù)
        self.expectedFailures_count = 0  # 期望失敗用例數(shù)
        self.unexpectedSuccesses_count = 0  # 非期望成功用例數(shù)
        
        self.know_exceptions = {}  # 已知異常字典,用于通過異常名來映射失敗原因交汤,如
        # self.know_exceptions = {'requests.exceptions.ConnectionError': '請求連接異常'}


        @property
        def summary(self):
        """組裝結(jié)果概要, details分按運(yùn)行順序和按類組織兩種結(jié)構(gòu)"""

        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]  # 每個(gè)測試用例結(jié)果對象轉(zhuǎn)為其字典格式的數(shù)據(jù)
        )
        return data

測試開始和測試結(jié)束

使用log信息代替原來的print輸出到stream流,這里使用的是筆者發(fā)布的開源包logz芙扎,安裝方法為:

pip install logz

logz非常方便配置和使用星岗,支持方便的配置,單例纵顾,DayRoting伍茄,準(zhǔn)確的調(diào)用追溯以及l(fā)og到Email等,詳細(xì)使用方法可參考:https://github.com/hanzhichao/logz施逾。

TestResult類中的verbosity屬性用于控制輸出信息的詳細(xì)等級敷矫,unittest.TextTestResult分為0,1汉额,2三級曹仗,作者這里也采用3級模式,邏輯稍有不同蠕搜,這里設(shè)計(jì)的邏輯如下怎茫。

  1. verbosity>1時(shí):輸出整個(gè)執(zhí)行開始和結(jié)束信息,每個(gè)用例除自身print輸出外,打印兩條開始和結(jié)束兩條日志轨蛤,分別顯示用例名稱描述+執(zhí)行時(shí)間和執(zhí)行結(jié)果+持續(xù)時(shí)間蜜宪。
  2. verbosity為1時(shí):不輸出整體開始和結(jié)束信息,只每天用例輸出用例方法名和執(zhí)行狀態(tài)一行日志祥山。
  3. verbosity為0時(shí):不輸出任何信息圃验,包括錯誤信息。

以下為對父類執(zhí)行開始和執(zhí)行結(jié)束方法的重寫缝呕。

import time
from logz import log  # 需要安裝logz

def time_to_string(timestamp: float) -> str:
    """時(shí)間戳轉(zhuǎn)時(shí)間字符串澳窑,便于日志中更易讀""
    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):
        """整個(gè)執(zhí)行開始"""
        self.start_at = time.time()  # 整個(gè)執(zhí)行的開始時(shí)間
        if self.verbosity > 1:
            self._log(f'===== 測試開始, 開始時(shí)間: {time_to_string(self.start_at)} =====')

    def stopTestRun(self):
        """整個(gè)執(zhí)行結(jié)束"""
        self.end_at = time.time()  # 整個(gè)執(zhí)行的結(jié)束時(shí)間
        self.duration = self.end_at - self.start_at  # 整個(gè)執(zhí)行的持續(xù)
        self.success = self.wasSuccessful()  # 整個(gè)執(zhí)行是否成功
        if self.verbosity > 1:
            self._log(f'===== 測試結(jié)束, 持續(xù)時(shí)間: {self.duration}秒 =====')

由于父類中的startTestRun和stopTestRun沒有任何內(nèi)容,此處不需要再調(diào)用父類的方法供常。

原始的unittest.TextTestRunner中對整個(gè)執(zhí)行時(shí)間的統(tǒng)計(jì)是在result對象外的摊聋,此處集成到result對象中,已使result的結(jié)果信息更完整栈暇。

用例開始和用例結(jié)束

捕獲用例輸出信息麻裁,在用例中常常會有print信息或出錯信息,這里面的信息是直接寫到系統(tǒng)標(biāo)準(zhǔn)輸出stdout和stderr中的瞻鹏。要捕獲并記錄這些信息的話悲立,我們需要再執(zhí)行用例的過程中(從startTest到stopTest)將系統(tǒng)stdout和stderr臨時(shí)重定向到我們的io流變量中鹿寨,然后通過get_value()獲取其中的字符串新博。
可喜的是,父類unittest.TestResult中便提供了重定向和恢復(fù)輸出的參考方法脚草,我們稍微改動即可赫悄。

  1. 重寫恢復(fù)輸出流方法
    由于startTest父類中自動調(diào)用_setupOutput方法,并且強(qiáng)制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. 用例開始和結(jié)束方法
    def startTest(self, test: unittest.case.TestCase):
        """單個(gè)用例執(zhí)行開始"""
        super().startTest(test)  # 調(diào)用父類方法
        test.result = TestCaseResult(test)  # 實(shí)例化用例結(jié)果對象來記錄用例結(jié)果痪蝇,并綁定用例的result屬性
        self.testcase_results.append(test.result)  # 另外添加到所有的結(jié)果列表一份

        test.result.start_at = time.time()  # 記錄用例開始時(shí)間
     
        if self.verbosity > 1:
            self._log(f'執(zhí)行用例: {test.result.name}: {test.result.description}, 開始時(shí)間: {time_to_string(test.result.start_at)}')

    def stopTest(self, test: unittest.case.TestCase) -> None:
        """單個(gè)用例結(jié)束"""
        test.result.end_at = time.time()  # 記錄用例結(jié)束時(shí)間
        test.result.duration = test.result.end_at - test.result.start_at   # 記錄用例持續(xù)時(shí)間
        
        # 由于output要從_restoreStdout獲取,手動加入父類恢復(fù)輸出流的方法
        test.result.output = self._restoreStdout()
        self._mirrorOutput = False  # 是否重定向輸出流標(biāo)志

用例結(jié)果注冊

    def addSuccess(self, test):
        """重寫父類方法, 單個(gè)用例成功時(shí)在stopTest前調(diào)用"""
        test.result.status = TestStatus.SUCCESS
        self.successes.append(test)
        self.successes_count += 1
        super().addSuccess(test)

    @failfast
    def addFailure(self, test, err):
        """重寫父類方法, 用例失敗時(shí)在stopTest前調(diào)用"""
        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):
        """重寫父類方法, 用例異常時(shí)在stopTest前調(diào)用"""
        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):
        """重寫父類方法, 用例跳過時(shí)在stopTest前調(diào)用"""
        test.result.status = TestStatus.SKIPPED
        test.result.reason = reason
        self.skipped_count += 1
        super().addSkip(test, reason)

    def addExpectedFailure(self, test, err):
        """重寫父類方法, 用例期望失敗時(shí)在stopTest前調(diào)用"""
        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):
        """重寫父類方法, 用例非期望成功時(shí)在stopTest前調(diào)用"""
        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):  # 可以添加更多的用例進(jìn)行測試
            """測試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的字典格式數(shù)據(jù)冕房,建議使用pprint輸出躏啰,需要安裝pprint

注:由于和作者本人自己使用的TestResult類有所精簡和改動,尚未進(jìn)行更多的測試耙册,如有問題歡迎留言指正给僵。

其他函數(shù)和方法

  1. 用例狀態(tài)列
    為了方便修改狀態(tài)名稱,(如改成中文)详拙,這里使用用例狀態(tài)類帝际。
class TestStatus(object):
    SUCCESS = 'success'
    FAIL = 'fail'
    ERROR = 'error'
    SKIPPED = 'skipped'
    XFAIL = 'xfail'
    XPASS = 'xpass'
  1. 獲取平臺信息
import os
def get_platform_info():
    """獲取執(zhí)行平臺信息"""
    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):
        """重寫父類的轉(zhuǎn)換異常方法, 去掉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 默認(rèn)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'用例中l(wèi)evel設(shè)置:{case_level} 應(yīng)為整數(shù)格式')
    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:
    """時(shí)間戳轉(zhuǎn)時(shí)間字符串"""
    time_array = time.localtime(timestamp)
    time_str = time.strftime("%Y-%m-%d %H:%M:%S", time_array)
    return time_str


def get_platform_info():
    """獲取執(zhí)行平臺信息"""
    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):
    """用例測試結(jié)果"""

    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):
    """測試結(jié)果,補(bǔ)充整個(gè)過程的運(yùn)行時(shí)間"""

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

        super().__init__(stream, descriptions, verbosity)
        self.successes = []
        self.testcase_results = []  # 執(zhí)行的用例結(jié)果列表
        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):
        """組裝結(jié)果概要, details分按運(yùn)行順序和按類組織兩種結(jié)構(gòu)"""
        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(),  # 環(huán)境信息的最后狀態(tài)
            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):
        """重寫父類的轉(zhuǎn)換異常方法, 去掉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):
        """整個(gè)執(zhí)行開始"""
        self.start_at = time.time()
        if self.verbosity > 1:
            print(f'===== 測試開始, 開始時(shí)間: {time_to_string(self.start_at)} =====')

    def stopTestRun(self):
        """整個(gè)執(zhí)行結(jié)束"""
        self.end_at = time.time()
        self.duration = self.end_at - self.start_at
        self.success = self.wasSuccessful()
        if self.verbosity > 1:
            print(f'===== 測試結(jié)束, 持續(xù)時(shí)間: {self.duration}秒 =====')

    def startTest(self, test: unittest.case.TestCase):
        """單個(gè)用例執(zhí)行開始"""
        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'執(zhí)行用例: {test.result.name}: {test.result.description}, 開始時(shí)間: {time_to_string(test.result.start_at)}')

    def stopTest(self, test: unittest.case.TestCase) -> None:
        """單個(gè)用例結(jié)束"""
        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'結(jié)果: {test.result.status}, 持續(xù)時(shí)間: {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):
        """重寫父類方法, 單個(gè)用例成功時(shí)在stopTest前調(diào)用"""
        test.result.status = TestStatus.SUCCESS
        self.successes.append(test)
        self.successes_count += 1
        super().addSuccess(test)

    @failfast
    def addFailure(self, test, err):
        """重寫父類方法, 用例失敗時(shí)在stopTest前調(diào)用"""
        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):
        """重寫父類方法, 用例異常時(shí)在stopTest前調(diào)用"""
        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):
        """重寫父類方法, 用例跳過時(shí)在stopTest前調(diào)用"""
        test.result.status = TestStatus.SKIPPED
        test.result.reason = reason
        self.skipped_count += 1
        super().addSkip(test, reason)

    def addExpectedFailure(self, test, err):
        """重寫父類方法, 用例期望失敗時(shí)在stopTest前調(diào)用"""
        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):
        """重寫父類方法, 用例非期望成功時(shí)在stopTest前調(diào)用"""
        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)

在命令行中運(yùn)行python result.py結(jié)果如下:

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. 測試結(jié)果按測試類聚合 --- 已完成蔓同,帶整合統(tǒng)一的格式
  2. 對帶重試的測試結(jié)果進(jìn)行格式整合
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市蹲诀,隨后出現(xiàn)的幾起案子牌柄,更是在濱河造成了極大的恐慌,老刑警劉巖侧甫,帶你破解...
    沈念sama閱讀 217,826評論 6 506
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件珊佣,死亡現(xiàn)場離奇詭異,居然都是意外死亡披粟,警方通過查閱死者的電腦和手機(jī)咒锻,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,968評論 3 395
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來守屉,“玉大人惑艇,你說我怎么就攤上這事∧捶海” “怎么了滨巴?”我有些...
    開封第一講書人閱讀 164,234評論 0 354
  • 文/不壞的土叔 我叫張陵,是天一觀的道長俺叭。 經(jīng)常有香客問我恭取,道長,這世上最難降的妖魔是什么熄守? 我笑而不...
    開封第一講書人閱讀 58,562評論 1 293
  • 正文 為了忘掉前任蜈垮,我火速辦了婚禮,結(jié)果婚禮上裕照,老公的妹妹穿的比我還像新娘攒发。我一直安慰自己,他們只是感情好晋南,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,611評論 6 392
  • 文/花漫 我一把揭開白布惠猿。 她就那樣靜靜地躺著,像睡著了一般负间。 火紅的嫁衣襯著肌膚如雪偶妖。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,482評論 1 302
  • 那天唉擂,我揣著相機(jī)與錄音餐屎,去河邊找鬼。 笑死玩祟,一個(gè)胖子當(dāng)著我的面吹牛腹缩,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 40,271評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼藏鹊,長吁一口氣:“原來是場噩夢啊……” “哼润讥!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起盘寡,我...
    開封第一講書人閱讀 39,166評論 0 276
  • 序言:老撾萬榮一對情侶失蹤楚殿,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后竿痰,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體脆粥,經(jīng)...
    沈念sama閱讀 45,608評論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,814評論 3 336
  • 正文 我和宋清朗相戀三年影涉,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了变隔。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 39,926評論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡蟹倾,死狀恐怖匣缘,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情鲜棠,我是刑警寧澤肌厨,帶...
    沈念sama閱讀 35,644評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站豁陆,受9級特大地震影響柑爸,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜献联,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,249評論 3 329
  • 文/蒙蒙 一竖配、第九天 我趴在偏房一處隱蔽的房頂上張望何址。 院中可真熱鬧里逆,春花似錦、人聲如沸用爪。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,866評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽偎血。三九已至诸衔,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間颇玷,已是汗流浹背笨农。 一陣腳步聲響...
    開封第一講書人閱讀 32,991評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留帖渠,地道東北人谒亦。 一個(gè)月前我還...
    沈念sama閱讀 48,063評論 3 370
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親份招。 傳聞我的和親對象是個(gè)殘疾皇子切揭,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,871評論 2 354

推薦閱讀更多精彩內(nèi)容