测试准备失败后,资源由谁清理:从 unittest 到 XAT
沿部分准备成功、cleanup 再失败和 worker 被终止三条路径,解释清理登记、异常保留与资源生命周期。
接口测试的准备过程常常分成几步:先取得连接,再创建测试数据,最后构造请求。第三步失败时,前两步已经创建的资源仍然需要释放。把所有清理写在测试结束的一个函数里,是否足够,取决于测试框架在准备失败后还会调用哪些阶段。
先沿 unittest 的调用顺序看一遍:准备失败后,哪些函数还会运行?如果清理函数自己也报错,剩下的资源怎么办?标准库的 addCleanup 已经提供了相应机制,XAT 在此基础上继续记录每个阶段的失败。
先看 setUp 失败后会跳过什么
正常顺序是 setUp → test → tearDown → cleanups。如果 setUp 抛出异常,测试方法与 tearDown 不会执行,但已经登记的 cleanup 仍会执行。因此,只在 tearDown 里释放资源,覆盖不到准备过程部分成功、后续失败的情况。
下面是可以独立运行的 Python 示例。它用列表代替外部资源,观察调用顺序:
import io
import unittest
trace = []
class Example(unittest.TestCase):
def setUp(self):
trace.append("acquire A")
self.addCleanup(trace.append, "release A")
trace.append("acquire B")
self.addCleanup(trace.append, "release B")
raise RuntimeError("prepare C failed")
def test_body(self):
trace.append("test")
def tearDown(self):
trace.append("teardown")
result = unittest.TextTestRunner(stream=io.StringIO()).run(
unittest.defaultTestLoader.loadTestsFromTestCase(Example)
)
assert trace == ["acquire A", "acquire B", "release B", "release A"]
assert len(result.errors) == 1
print(trace)
从输出可以看到,C 准备失败后,B 和 A 仍按反序释放。原因是每次取得资源后,代码立即登记了对应清理函数。若等到所有准备结束才一起登记,C 抛异常时 A、B 就还没有清理记录。若 B 又依赖 A,先释放 B、再释放 A,也与这层依赖相符。
清理函数再失败,剩余清理怎样继续
手写一个大 finally 也能做清理,但其中第一次异常可能阻止后续语句执行。unittest 对各个 cleanup 分别收集异常,因此一个 cleanup 出错不会让后面登记栈中的其他项直接消失。标准库文档规定了这一顺序和失败后的调用行为。
下面故意让测试断言失败,再让一个 cleanup 出错。两个问题都应留在结果中,另一个 cleanup 仍然执行:
import io
import unittest
released = []
class TwoFailures(unittest.TestCase):
def test_body(self):
self.addCleanup(released.append, "connection closed")
self.addCleanup(lambda: 1 / 0)
self.assertEqual(1, 2, "business assertion")
result = unittest.TextTestRunner(stream=io.StringIO()).run(
unittest.defaultTestLoader.loadTestsFromTestCase(TwoFailures)
)
assert released == ["connection closed"]
assert len(result.failures) == 1
assert len(result.errors) == 1
XAT 沿用这些生命周期语义,阶段包装用于日志,结果收集器保留每个错误的原因。同一方法可以同时含断言失败与清理异常;它的主状态为 SCRIPT_ERROR,但原断言失败仍在 issues 中。若汇总只保留最后一个异常,排查者会失去原始测试结论;若仅保留断言失败,又会遗漏残留资源的线索。
资源的生命周期要与执行单位一起考虑
默认方法模式下,XAT 为每个方法启动解释器。连接与 Session 应由当前执行生命周期创建和关闭,不能假设文件中的类级 fixture 会被所有方法共享。文件模式才在同一进程里复用类和模块 fixture;对应资源应使用相匹配的类级、模块级清理方式,不能把方法级 cleanup 当成所有层次的替代品。
另一条边界来自强制终止。父进程在方法超时后杀掉 worker 时,Python 无法继续调用 tearDown 或 cleanup。操作系统会回收进程内资源,但外部已创建的订单、租约或账号状态不会因此恢复。业务需要独立的到期或回收约定;这部分不由 unittest 保证。
因此,前面的例子只完成了一半检查:它们验证 Python 还能运行时的清理顺序。还要另外模拟 worker 被终止,检查外部资源是否有到期或回收机制;这条路径无法由 addCleanup 的成功结果代替。执行单位见方法模式与文件模式,多错误怎样进入汇总见结果报告。