ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

从 runner = unittest.TextTestRunner() 讲透测试执行器

2026/9/16 9:32:21 拓冰建站 浏览量
从 runner = unittest.TextTestRunner() 讲透测试执行器 第一次在测试脚本里看到runner unittest.TextTestRunner()这行赋值时我甚至把变量名看成了unner——不是看错而是很多教程代码里随手写的变量名确实容易晃眼。它其实是runner一个真正决定 unittest 结果“怎么被记录、怎么被打印”的对象。很多初学者以为这行只是把某个类实例化一下甚至以为有了它就能自动跑测试结果运行完发现毫无输出转头就把它删掉。事实上TextTestRunner是 unittest 框架里最容易被低估的执行入口它负责接收一套TestSuite逐条调度测试方法把成功、失败、错误、跳过全部统计成TestResult最后按 verbosity 的设定输出到终端。这篇文章我会从构造函数到源码流程把这行代码背后的事情一次讲透适合刚开始用 unittest 写用例、也想理解测试框架该如何扩展的人。1. 这行赋值到底创建了什么从构造函数看 TextTestRunner1.1 默认参数里藏着的最关键选择unittest.TextTestRunner()并不是一个无参类。它的构造函数签名在 Python 3.11 及以前大致长这样TextTestRunner(streamNone, descriptionsTrue, verbosity1, failfastFalse, bufferFalse, resultclassNone, warningsNone, *, tb_localsFalse)在 Python 3.12 之后又增加了durationsNone。也就是说你写runner unittest.TextTestRunner()时真正的调用其实是runner unittest.TextTestRunner( streamNone, descriptionsTrue, verbosity1, failfastFalse, bufferFalse, resultclassNone, warningsNone, tb_localsFalse, )这里最值得注意的不是verbosity而是streamNone。很多人以为 TextTestRunner 的输出会走sys.stdout于是用重定向 stdout 的方式去抓测试日志结果抓到个空。源码里的逻辑是if stream is None: stream sys.stderr self.stream stream也就是说默认输出流是 stderr而不是 stdout。终端里看起来没区别因为两者都打到控制台但一旦做输出捕获、管道重定向或者 CI 日志分类这个细节就会立刻坑人。1.2 为什么说 runner 只是“执行外壳”而不是“发现器”TextTestRunner 本身不会去扫描测试文件不会自动发现test_*.py也不知道你的测试类长什么样。它的输入必须是一套已经组装好的TestSuite也就是一个“按顺序排列的测试集合”。发现测试、加载测试是TestLoader和discover的活TextTestRunner 只干三件事根据传入参数创建一个结果对象result执行suite.run(result)让测试方法在内部依次运行测试跑完后由结果对象把错误列表、耗时、汇总统计打印出来。所以你在很多开源项目里看到的模式是suite unittest.TestLoader().loadTestsFromTestCase(MyTestCase) runner unittest.TextTestRunner(verbosity2) result runner.run(suite)先有 suite再有 runner最后调run()。这一步少掉任何一个测试都不会“自动跑起来”。了解了这层关系之后你就能理解为什么unittest.main()看起来那么简单它内部其实就是用默认的TextTestRunner去跑。main()省事但它把加载、执行、退出码全部封装成了一个黑盒而你手动创建 runner换来的就是对执行过程的完全掌控。2. 最小可运行示例带你看完一整轮测试生命周期2.1 一段能直接跑的代码先把最简单的情况写出来。假设有一个求和函数和一个测试类import unittest def add(a, b): return a b class TestMath(unittest.TestCase): def test_add_positive(self): self.assertEqual(add(1, 2), 3) def test_add_zero(self): self.assertEqual(add(0, 0), 0) if __name__ __main__: suite unittest.TestLoader().loadTestsFromTestCase(TestMath) runner unittest.TextTestRunner(verbosity2) result runner.run(suite)运行后终端会输出类似这样的内容test_add_positive (__main__.TestMath) ... ok test_add_zero (__main__.TestMath) ... ok ---------------------------------------------------------------------- Ran 2 tests in 0.001s OK注意第一行的测试描述test_add_positive (__main__.TestMath)这是TextTestResult.getDescription()生成的后面的ok表示成功。如果某个测试断言失败你会看到FAIL如果是测试代码自身抛异常则是ERROR。runner.run(suite)返回的result对象不是空摆设它上面挂着几乎你想知道的全部统计信息result.testsRun # 实际跑了多少条用例 result.wasSuccessful() # 是否全部通过 result.failures # 失败用例列表 result.errors # 出错用例列表 result.skipped # 跳过用例列表 result.expectedFailures # 预期失败但确实失败 result.unexpectedSuccesses # 预期失败结果却成功了顺手在脚本末尾加一句print(result.testsRun, result.wasSuccessful())你就能看到这些属性怎么用。2.2 runner.run(suite) 内部发生了什么很多人把run()理解成“执行测试方法”的单一入口其实这一层有完整的调度链路。简化后的源码逻辑大致是def run(self, test): result self._makeResult() result.failfast self.failfast result.buffer self.buffer result.tb_locals self.tb_locals 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() result.printErrors() self.stream.writeln(fRan {result.testsRun} tests ...) # 根据 wasSuccessful() 打印 OK 或 FAILED return result_makeResult()并不是一个复杂工厂它把 runner 的三个构造参数透传给了结果类def _makeResult(self): return self.resultclass(self.stream, self.descriptions, self.verbosity)重点在test(result)这一行。test是TestSuite对象TestSuite.__call__内部会遍历自己持有的测试项逐个执行。如果某个测试项本身还是TestSuite就递归往下如果是一个TestCase就调用它的__call__(self, result)最终走到TestCase.run(result)# 大致流程 result.startTest(test) try: test.setUp() test.testMethod() test.tearDown() except AssertionError as e: result.addFailure(test, sys.exc_info()) except Exception as e: result.addError(test, sys.exc_info()) else: result.addSuccess(test) finally: result.stopTest(test)所以“跑一个测试”不只是执行test_xxx这个方法还包含setUp、测试方法本身、tearDown三件事以及异常到addFailure/addError的分发。这也是为什么 unittest 底层有“测试协议”的说法任何对象只要实现了run(result)或__call__(self, result)理论上都可以被TestSuite接收并执行。TextTestRunner 本身不在乎你传进来的是从 loader 拿来的 suite还是手工拼的TestSuite。2.3 TextTestResult统计、格式化、打印三位一体默认情况下_makeResult()创建的是TextTestResult。这个类继承了基础的TestResult额外承担了三件事。第一是统计。TestResult里有testsRun、failures、errors、skipped等属性每次addSuccess、addFailure、addError时都会更新。wasSuccessful()挂在基类上逻辑很简单只有在没有失败、没有错误、没有非预期成功时才返回 True。第二是格式化。当某个测试失败或报错时addFailure和addError都会调用_exc_info_to_string()把当前的(type, value, traceback)转成一段带Traceback (most recent call last):的字符串然后存进failures或errors列表。注意这里存的已经是格式化后的文本不是异常对象所以你想在自定义结果类里做“失败重试”或者“提取异常类型”得在写入前的钩子里处理而不是事后去 parse 字符串。第三是打印。printErrors()会把errors和failures里积累的内容用分隔线划开逐条输出startTest()和addSuccess()会根据 verbosity 决定要不要打印测试名、ok、点号等等。这些打印全部写到 runner 的 stream 上所以 stream 换成io.StringIO()就能把测试输出“装”进内存。这一段搞清楚后你对 runner 的认知就不再停留在“能跑测试”这个层面了。下面我们来逐个拆解构造参数。3. 构造参数逐个拆解verbosity 之外还有多少控制项3.1 verbosity 从 0 到 2 到底差在哪verbosity 参数是最直观的也是一个最容易描述清楚的东西。它有三个档位verbosity每个测试的即时输出典型场景0无任何逐用例输出安静模式只看最终汇总1一个字符成功.失败F错误E跳过sCI 日志、默认模式2每个测试一行显示测试名和 ok/FAIL/ERROR本地调试、排查问题我在实际项目中见过不少误解比如有人以为默认 verbosity1 时每个测试会显示“点”和“F/E”是 pytest 的行为其实 unittest 一直就是这样。区别只是 verbosity2 时TextTestResult.startTest会先打印self.getDescription(test)而 addSuccess 等回调会打印ok、FAIL、ERRORverbosity1 时则用单字符做进度显示verbosity0 则连进度字符都没有直接跑到最后输出汇总和错误详情。有一点值得提醒verbosity2 下输出的测试描述并不总是“方法名”当测试方法有 docstring 时unittest 会把 docstring 当作 short description 显示。比如def test_add_positive(self): 正数相加 self.assertEqual(add(1, 2), 3)verbosity2 时会显示正数相加 (__main__.TestMath) ... ok如果不想让 docstring 掺和进测试名可以把descriptionsFalse传进 runner。3.2 stream 和 descriptions输出去向与测试描述stream是 TextTestRunner 最容易出价值也最容易踩坑的参数。它的要求很简单是一个有write()方法的类文件对象最好也实现flush()因为测试过程会频繁写入。常见用法有几种。第一种捕获到内存对象import io import unittest class TestDemo(unittest.TestCase): def test_ok(self): self.assertEqual(1, 1) suite unittest.TestLoader().loadTestsFromTestCase(TestDemo) stream io.StringIO() runner unittest.TextTestRunner(streamstream, verbosity2) result runner.run(suite) print(stream.getvalue())有人可能疑惑我都跑了runner.run(suite)为什么控制台没看到输出因为上面的代码把输出全写进stream了控制台当然看不到。拿到stream.getvalue()之后你可以把它拼进邮件通知、写进日志文件、或者作为 CI 的一个 artifact。第二种写入文件with open(test_report.txt, w, encodingutf-8) as f: runner unittest.TextTestRunner(streamf, verbosity2) runner.run(suite)这样测试报告就落在磁盘上不干扰终端其他日志。第三种保留给第三方报告器比如有人写 HTMLTestRunner 时本质就是用一个能累积 HTML 片段的流对象替换默认 stderr。descriptions参数则控制描述来源。默认 True 时结果类会用test.shortDescription()作为显示描述如果设为 False就直接用测试方法的id()也就是模块名.类名.方法名。这个参数在审计输出、固定报告格式的时候很有用因为 docstring 一改报告里的测试名就变了不利于 diff。3.3 failfast、buffer、warnings、tb_locals 的实战价值failfastTrue会让 runner 在遇到第一个失败或错误时立刻停止后续测试。注意它不是“跳过”后面的用例而是不再执行。底层逻辑是TestResult.stop()设置一个标志位TestCase.run()在每次执行前检查这个标志。这个参数在冒烟测试和快速反馈场景下很好用比如你在 CI 上只想确认主流程是否正常没必要把 500 条用例全部跑完。bufferTrue是我个人很喜欢的参数。它会把每个测试执行期间的stdout和stderr输出缓存起来如果测试通过这些输出就不会出现在最终结果里如果测试失败或报错缓存的内容会被附加到错误信息里一并展示。这样能有效避免一堆print()调试日志刷屏。实现上它依赖contextlib.redirect_stdout/redirect_stderr所以子进程或者 C 扩展直接写文件描述符的输出不在捕获范围内这点要提前有预期。warnings参数控制测试运行期间 Python 警告的过滤方式可直接传ignore、always、error等字符串。它最终会被传给warnings.simplefilter()。比较实用的玩法是warningserror把 DeprecationWarning 变成异常强制暴露某些库的过时 API。tb_localsTrue是 Python 3.5 以后才有的 keyword-only 参数。打开后测试失败或报错时traceback 的最后会附带异常发生处的局部变量名和值。这是一个排查利器尤其在 CI 上没法本机复现问题时特别有用。但要注意局部变量可能包含密码、token 等敏感信息打印到日志里要注意脱敏。3.4 新版本才有的 durations 参数与兼容写法Python 3.12 给TextTestRunner增加了durations参数。它的用法是传入一个整数表示要展示最慢的多少个测试runner unittest.TextTestRunner(verbosity1, durations5) result runner.run(suite)运行结束后stream 里会多出一段类似Slowest 5 tests: 0.102s test_slow_query (tests.test_db.TestDB) 0.050s test_big_file (tests.test_io.TestIO) ...这对定位性能瓶颈非常有用。但它只统计耗时超过一定阈值的测试具体阈值和输出格式在不同小版本里可能有调整。如果你维护的代码要兼容 Python 3.11 及以下就不能在构造参数里直接写durations5否则会报TypeError: __init__() got an unexpected keyword argument durations。兼容写法有两个思路。一个是用版本号判断import sys if sys.version_info (3, 12): runner unittest.TextTestRunner(verbosity1, durations10) else: runner unittest.TextTestRunner(verbosity1)另一个是用try/except TypeError因为参数错误只在运行时才暴露。我更推荐版本号判断因为语义更清楚代码审查的人一眼就看明白为什么这里要分支。最后再说resultclass它是所有参数里扩展性最强的。它本身不是一个“行为开关”而是一个指向结果类对象的引用默认是unittest.TextTestResult。你可以传入任何继承了TextTestResult的子类从而完全接管“测试结果如何记录、如何打印”这两个环节。下一章专门展开。4. 用 resultclass 重写结果处理逻辑从普通文本到自定义报告4.1 自定义结果类要重写哪些钩子如果你只想调整输出颜色、统计维度、报告格式不建议去改 TextTestRunner 本身更优雅的做法是继承TextTestResult然后通过resultclass注入。一个结果类的生命周期里有几个关键钩子startTestRun()整套 suite 开始前调用适合做初始化、计时清零startTest(test)每一条用例开始前调用addSuccess(test)用例通过addFailure(test, err)断言失败addError(test, err)用例自身抛异常addSkip(test, reason)用例被跳过addExpectedFailure(test, err)预期失败但确实失败addUnexpectedSuccess(test)预期失败结果却成功stopTest(test)每一条用例结束后调用stopTestRun()整套 suite 结束后调用适合做最终汇总、写报告。特别注意TextTestResult.__init__(self, stream, descriptions, verbosity)这个签名是_makeResult()调用时固定的自定义结果类若需要额外参数可以通过 runner 子类或者实例属性传入但不要改这个初始化签名否则 runner 会构造失败。一个最常见的自定义场景是给结果加颜色。比如失败时输出红色的F成功时输出绿色的点。做法就是重写addSuccess和addFailure在外层套 ANSI 转义码import unittest class ColorTextResult(unittest.TextTestResult): def addSuccess(self, test): super().addSuccess(test) if self.dots: self.stream.write(\033[32m.\033[0m) self.stream.flush() elif self.showAll: self.stream.writeln(\033[32mok\033[0m) def addFailure(self, test, err): super().addFailure(test, err) if self.dots: self.stream.write(\033[31mF\033[0m) self.stream.flush() elif self.showAll: self.stream.writeln(\033[31mFAIL\033[0m) class ColorRunner(unittest.TextTestRunner): resultclass ColorTextResult调用方式不变runner ColorRunner(verbosity1) result runner.run(suite)注意super().addSuccess(test)必须保留因为基类里除了打印ok之外还承担了testsRun 1这类统计任务。如果不调用父类测试数量会变成 0最终汇总就是错误的。4.2 一个基于 TextTestResult 的 JSON 报告器比加颜色更实际的需求是把测试结果整理成结构化数据交给上层平台。这里我给一个可以改造成内部工具的精简示例import json import unittest class JsonResult(unittest.TextTestResult): def __init__(self, stream, descriptions, verbosity): super().__init__(stream, descriptions, verbosity) self.records [] def addSuccess(self, test): super().addSuccess(test) self.records.append({ name: self.getDescription(test), status: success, }) def addFailure(self, test, err): reason self._exc_info_to_string(err, test) super().addFailure(test, err) self.records.append({ name: self.getDescription(test), status: failure, reason: reason, }) def addError(self, test, err): reason self._exc_info_to_string(err, test) super().addError(test, err) self.records.append({ name: self.getDescription(test), status: error, reason: reason, }) def addSkip(self, test, reason): super().addSkip(test, reason) self.records.append({ name: self.getDescription(test), status: skip, reason: str(reason), }) class TestSample(unittest.TestCase): def test_ok(self): self.assertEqual(2 2, 4) suite unittest.TestLoader().loadTestsFromTestCase(TestSample) runner unittest.TextTestRunner(verbosity0, resultclassJsonResult) result runner.run(suite) print(json.dumps(result.records, ensure_asciiFalse, indent2))有人会问为什么我不直接在addFailure里把异常信息直接存成对象而要调_exc_info_to_string转成字符串因为一旦丢给 JSONtraceback 本来也只会被序列化成文本所以先格式化没有损失。如果有更复杂的需求比如根据异常类型分类你可以不调用父类的 addFailure而是先处理err[0]拿到异常类型再构造自己的数据结构。要注意的是自定义结果类里如果完全绕开父类实现必须自己维护failures、errors、testsRun等属性的一致性否则 runner 最后打印的Ran N tests和OK/FAILED就是错的。我的习惯是永远先super()再追加自己的逻辑除非有极其特殊的原因。4.3 在 CI 里手动 runner 的正确退出码姿势手动创建 runner 之后有一个非常常见的坑脚本跑完测试CI 居然显示成功哪怕测试明明失败了。原因很简单TextTestRunner.run()只是返回一个result对象它不会主动设置进程退出码。你需要在脚本最后自己判断import sys import unittest suite unittest.defaultTestLoader.discover(tests) runner unittest.TextTestRunner(verbosity1) result runner.run(suite) sys.exit(0 if result.wasSuccessful() else 1)如果不加这一行Python 脚本正常结束就是退出码 0CI 当然认为任务成功。很多 Jenkins 或 GitLab CI 上“假绿”的测试任务排查到最后都是这个问题。另外一个更隐蔽的坑是当测试进程因为failfastTrue提前停止时testsRun可能小于预期的用例总数但wasSuccessful()依然能正确反映“是否有失败”。CI 汇总时需要区分“全部跑完且成功”和“提前停止但当前未失败”否则冒烟测试和全量测试的阈值会混淆。5. 实战中必须绕开的四个坑5.1 同一个 suite 被重复执行时脏状态比计数更危险有段时间我在写一个内部收集测试结果的工具为了演示方便把同一个 suite 对象连续run了两次。当时只看返回的 result 计数发现两次都是“2 tests”误以为没问题。后来其中一个测试类里用了一个类变量做累加器第二次运行的结果完全不一样。问题根源在于TestSuite里保存的是TestCase实例而不是“测试方法描述符”。同一个实例被第二次执行时setUp会再次执行但实例上已有的属性、mock 状态、类变量累积不会自动重置。也就是说run()返回的result确实是新创建的但测试对象本身不是。所以建议养成习惯每次运行前都通过TestLoader重新加载测试而不要复用一个已经跑过的 suite。如果确实需要重复执行同一批测试例如做稳定性冒烟应该重新构造TestSuite。5.2 以为设置了 verbosity 却没看到详细输出的排查链路常见现象写了runner unittest.TextTestRunner(verbosity2)但运行后终端仍然只显示点号或者什么都没有。排查时应按下面思路走一遍。第一确认你调用的确实是这个 runner而不是某处又来了一个unittest.main()。在含有if __name__ __main__: unittest.main()的脚本里手动创建 runner两者混在一起后者会先执行并把进程退出前者根本没机会跑。第二确认输出去了哪里。如果 stream 被上游代码改成io.StringIO()或文件对象终端自然看不到。在全套代码里搜TextTestRunner(stream是否传了非空参数。第三确认你的结果类是不是TextTestResult。如果 resultclass 换成了自定义类并且重写了startTest但没有调用父类verbosity2 的“逐条显示测试名”就不会出现。这不一定算 bug但会让你误以为 verbosity 失效。第四注意输出缓冲。因为默认 stream 是 stderr而很多脚本调试时用 print 观察 stdout两边混在一起时顺序会很怪。可以先重定向到文件再确认每一行来自哪个流。5.3 自定义 resultclass 后“错误全部消失”的根因自定义 resultclass 最常见的问题不是“没有输出”而是“明明测试失败了runner 最后仍然打印 OK”。我见过不少新手在addFailure里只写了self.records.append(...)忘了调用super().addFailure(test, err)。TextTestResult.wasSuccessful()判断的是基类里failures、errors、unexpectedSuccesses三个列表是否为空。父类addFailure的职责之一就是把用例追加进failures列表。你不调父类列表就是空的后续wasSuccessful()自然返回 True。还有一个连带问题不调父类stream上的F字符也不会输出因为显示逻辑写在父类实现里。所以排查自定义结果类“异常安静”的问题时第一件事永远是检查super()调用是否完整。反过来如果你重写了printErrors也要注意它依赖self.errors self.failures这个列表拼接顺序。若你在子类里改了这两个列表的结构比如存成字典打印方法会直接崩溃。5.4 版本差异tb_locals、durations 与流行为不一致除durations是 3.12 新增外tb_locals是 3.5 新增的result.startTestRun/stopTestRun也是后来补充进协议的。如果你的代码要服务多个 Python 版本构造 runner 的参数一定要做兼容判断。另一个版本差异是 buffer 行为的细节。早期版本里bufferTrue只能捕获 Python 层sys.stdout/sys.stderr的写入到了现代版本捕获机制已经用contextlib.redirect_stdout/stderr实现行为更接近“进程内重定向”。但无论如何C 扩展里直接写 POSIX 文件描述符的输出仍然捕获不到。如果你遇到“明明代码里调了 print测试失败时却看不到输出”先确认 print 是否被重定向到了非 stdout 流或者是否用了第三方的 logging handler 直连文件。版本差异还有一个容易被忽略的点TextTestRunner的durations输出格式在不同 patch 版本之间可能变了。如果你们的团队同时跨多个 3.12 小版本不要对“Slowest N tests”这段文本做精确字符串匹配最好拿到 datetime 或自定义格式化后再用。6. 从 runner 到测试框架雏形我的使用心得最后说点这些年反复用出来的体会。我在很多项目里看过两种极端要么所有测试脚本都写成unittest.main()遇到定制需求就无从下手要么一上来就上 pytest、nox、tox 这类重量级工具杀鸡用牛刀。其实runner unittest.TextTestRunner(resultclassMyResult, verbosity1)这一行就是一个轻量测试执行器的核心很多需求根本不需要引入额外依赖。我个人的习惯是单单跑一次验证直接用unittest.main(verbosity2)需要做多模块合并、自定义报告、控制退出码就手动组合TestLoader和TextTestRunner需要把结果喂给内部平台就写一个继承TextTestResult的子类通过resultclass注入。这样既保留了 unittest 原生的稳定执行链路又没被框架绑定死。一个小技巧可以作为扩展起点把 stream 替换成你自定义的“报告缓冲器”同时在 resultclass 里记录结构化数据跑完测试后两者拼成一份 Markdown 或 JSON 报告既不影响终端输出又能自动归档。这就是 TextTestRunner 最舒服的用法——它足够简单简单到你可以轻松接管它的输出而不是被它的输出接管。