Python unittest最佳实践:从setUp到断言的深度解析
1. setUp没写对断言再多也白搭1.1 setUp不是把变量写上就完了很多人写unittest用例时把setUp当成一个普通的初始化函数习惯性地把测试类里所有要用到的变量一股脑塞进去。这个习惯本身没问题但我在实际项目里见过太多次测试挂了查了半天最后发现是setUp里造的测试数据本身就跑偏了被测代码压根没被执行到。setUp真正的作用是给每个测试方法准备一个确定性的起点。它和构造函数的区别在于每个测试方法执行前unittest都会重新调用一次setUp。也就是说同一个测试类里有10个测试方法setUp就会被调用10次。这意味着你在setUp里创建的对象、写入的临时文件、连上的数据库事务都必须具备可重复创建、可重复销毁的能力。举个例子有一个订单服务类你需要在setUp里构造一个带状态的订单对象import unittest class OrderServiceTest(unittest.TestCase): def setUp(self): self.order Order(statuspending, amount100) self.service OrderService(dbMockDB())这段代码看起来没问题但有个隐患如果Order类内部有一个类级别的共享状态比如订单编号自增计数器那么每次setUp创建的对象虽然字段相同但编号不同导致某些依赖具体编号的断言时灵时不灵。我在代码评审里看到过不少这种问题解决方式是setUp里显式重置共享状态或者使用工厂函数生成数据。1.2 花半年才搞明白的setUp执行时机细节setUp的执行时机其实比大多数人以为的要微妙。当你运行一个测试类时unittest的执行顺序是先执行setUpClass如果有再逐个测试方法执行setUp - 测试方法 - tearDown最后执行tearDownClass如果有。这里有一个容易被忽略的点setUpClass是在类级别执行的它里面创建的对象会被所有测试方法共享。如果你在setUpClass里创建了一个可变对象某个测试方法把它改了另一个测试方法再用它就会受污染。setUpClass - setUp - test_a - tearDown - setUp - test_b - tearDown - tearDownClass我自己踩过一次坑在setUpClass里创建了一个mock的HTTP客户端结果test_a测试登录失败时把这个客户端的token清掉了test_b测试登录成功后访问用户信息时直接拿着空的token去请求断言失败还以为是业务代码出问题了。所以我的建议是setUpClass只放只读的、重量级的、真正需要共享的初始化比如数据库连接池、外部服务客户端。setUp里放每个测试方法独立的数据必须保证互不影响。如果你拿不准某个对象是不是会被测试方法改动就放进setUp别放进setUpClass。1.3 setUp失败和断言失败是两件事这一点很多人没意识到setUp里抛出的异常和测试方法里断言失败抛出的异常在unittest的统计口径里是分开的。setUp里的错误会被记为ERROR断言失败才会记为FAILED。这个区分不是咬文嚼字而是排查问题时的重要线索。如果一套测试里有好几个用例同时报ERROR大概率是setUp这个公共环节出了问题而不是业务逻辑坏了。如果只是个别用例报FAILED才需要聚焦到具体断言上。所以写setUp时要有一个心态setUp不是测试代码的一部分而是测试环境的搭建脚本。它出了问题往往意味着测试环境整体不可信而不是某个功能坏了。在持续集成流水线里这种区分能帮你快速定位到底该去看业务代码的提交还是该去看测试基建的变更。2. 断言方法选型不要一个assertTrue走天下2.1 assertEqual比assertTrue强在哪我见过不少从pytest或者其他测试框架转过来的新人写出来的unittest断言是这样的self.assertTrue(result expected)这种写法不能算错但它有个致命缺点断言失败时你只能看到AssertionError: False is not true完全看不到result和expected分别是什么。在CI日志里看到这种信息你只能回到本地加print重新跑浪费时间。而assertEqual就聪明得多self.assertEqual(result, expected)当断言失败时unittest会输出两个对象的具体内容AssertionError: {status: paid, amount: 100} ! {status: pending, amount: 100}这种差异信息能让你直接定位到是哪个字段不对。对于字典、列表这种嵌套结构unittest还会逐层找出第一个不一致的位置排查效率完全不是一个量级。所以我的建议很直接只要是判断两个值是否相等一律用assertEqual不要用assertTrue加。assertTrue留给那些没有现成比较方法的场景比如判断一个布尔标志位、判断一个回调是否被调用。2.2 浮点数、列表、字典的比较坑assertEqual也不是万能钥匙它在比较浮点数时有一个经典的问题self.assertEqual(0.1 0.2, 0.3) # 会失败这是二进制浮点数的精度问题跟Python本身无关任何语言都一样。你需要在断言时指定精度用assertAlmostEqualself.assertAlmostEqual(0.1 0.2, 0.3, places7)places表示小数点后保留几位默认是7位对于大多数业务场景够用了。如果你的计算涉及很大或很小的数值可以改用delta参数表示两个值的差的绝对值不超过多少self.assertAlmostEqual(calculate_tax(1000), 60.0, delta0.01)至于列表和字典的比较assertEqual默认是逐个元素比较的列表会按索引位置对齐字典会比对键和值。这个行为在绝大多数场景下是正确的但有一个坑列表顺序敏感字典顺序不敏感。如果你的接口返回的列表顺序不稳定比如从数据库查询没有order by那么assertEqual会随机性失败。这时候应该先对列表排序再比较或者用assertCountEqualPython 3.2来比较两个列表的元素是否一致、忽略顺序self.assertCountEqual(result_items, expected_items)另外比较巨大的字典或嵌套结构时断言失败的信息可能很长刷屏严重。实用做法是先把差异提取出来单独看差异再决定怎么改。我习惯在断言前加一行日志输出关键字段让CI日志更友好。2.3 断言表达的是证据不是感觉断言方法选型背后其实是一种测试设计理念断言应当是可读的证据链而不是程序员的自我安慰。我见过这样一种用法一个人写了一个测试断言接口返回的code码是200就认为覆盖了功能。但实际上接口返回200只是表示请求被处理了并不代表业务逻辑执行正确。比如一个下单接口即使库存扣减失败也可能返回200加一个业务错误码。这时候光断言HTTP状态码就是断言了但没完全断言。正确的姿势是把断言的粒度落到业务结果上response client.create_order(payload) self.assertEqual(response.status_code, 200) self.assertEqual(response.json()[code], SUCCESS) self.assertEqual(response.json()[data][order_status], pending)这样的断言才真正验证了下单成功这个业务行为而不是仅仅验证了接口没崩。断言方法选型只是技术层面的选择更重要的是你得清楚每个断言在为哪个事实作证。3. 断言时机与用例结构在正确的位置断下去3.1 一条用例到底断言几个点才合适一条测试用例只能有一个断言这句话在测试圈流传很广甚至被奉为教条。但我在实际工作中发现这个规则被机械执行后反而会让测试代码变得异常啰嗦、难以维护。举个反例def test_create_order_success(self): result self.service.create_order(user_id1, items[...]) self.assertEqual(result.status_code, 200)如果只允许一个断言你就没法验证返回的订单号格式、订单状态、金额计算这些关键业务结果。那你只能拆出四五条用例每条用例都执行一遍下单流程只是最后断言不同的点。资源开销翻了几倍收益却微乎其微。我的经验是一条用例应当围绕一个业务行为展开而这个行为的结果可能需要多个断言来锁定。核心判断标准是这些断言是否在验证同一个行为的同一个方面。如果是就写在一起如果第二个断言已经是在验证另一个独立行为那才需要拆开。比如这样是合理的def test_create_order_calculates_total_amount(self): result self.service.create_order(user_id1, items[item_a, item_b]) self.assertEqual(result.order_id, ORD-2025-0001) self.assertEqual(result.total_amount, item_a.price item_b.price) self.assertEqual(result.status, pending)这三个断言都在回答下单成功以后返回的结果对不对这一个问题放在一起能保证信息完整拆开反而要在每一条用例里重复准备相同的下单数据。3.2 subTest循环断言不中断的好东西当你需要对一组数据执行相同的断言逻辑时用循环加assert是一条普遍的做法但它有一个很烦人的问题只要第一个数据不满足断言整个测试就停了后面的数据根本没机会验证。比如验证一个校验函数对一批非法输入的拒绝行为invalid_payloads [ {name: , age: 20}, {name: 张三, age: -1}, {name: 张三, age: 200}, {name: x * 101, age: 20}, ] for payload in invalid_payloads: with self.assertRaises(ValidationError): self.validator.validate(payload)如果第二条数据就抛了断言失败第三条、第四条数据就白准备了你下一次还得重新跑一遍才能看到第四条的情况。用subTest可以完美解决这个问题for payload in invalid_payloads: with self.subTest(payloadpayload): with self.assertRaises(ValidationError): self.validator.validate(payload)subTest会把每次循环当成一个独立的子用例来跑任何一个子用例失败其他的仍然继续执行。运行结果里也会精确告诉你哪个payload导致了失败FAIL: test_validate_invalid_payloads (__main__.ValidatorTest.test_validate_invalid_payloads) SubTest: payload{name: 张三, age: -1}这个特性在数据驱动测试场景里特别有用。我自己的习惯是只要断言是放在循环里的一律用subTest包一层它不仅能保留失败现场还能一次跑完所有数据产出完整报告。3.3 别把断言塞进没有业务含义的角落里还有一种常见的坏味道是为了防止代码没跑到而随手加断言。比如def test_send_notification(self): notification_service NotificationService() self.assertIsNotNone(notification_service) # 这行毫无意义 notification_service.send(user_id1, messagehello)这个断言没有验证任何业务行为只是在自我安慰对象创建成功了。真正的问题不是对象是否为None而是send方法执行后的效果是什么用户是否收到了通知通知内容是否正确发送状态是否成功所以断言应该出现在业务结果产生之后并且只针对有具体含义的条件。我建议写断言前问自己一个问题如果这个断言被删掉测试还能发现什么bug如果答案是什么bug都发现不了那这个断言就是冗余的应该删掉。4. 断言失败时信息质量决定你能否快速定位4.1 msg参数别浪费它才是排查入口在所有断言方法里最后一个可选的msg参数是很多人的盲区。如果你看过CI日志里那些纯粹由断言库生成的信息就知道没有上下文的失败信息有多让人抓狂AssertionError: 123 ! 456你根本不知道这个123是什么456是什么是哪个接口、哪条业务逻辑产生的。哪怕你能通过堆栈信息定位到具体哪一行也要回代码里翻半天才能想起来这个变量代表什么。解决方式特别简单在重要的断言上加上msg参数self.assertEqual( order.status, paid, msgf订单 {order.order_id} 支付后状态错误当前状态: {order.status}, )这样CI日志里就能直接看到AssertionError: pending ! paid : 订单 ORD-2025-0001 支付后状态错误当前状态: pending排查效率完全不一样。msg参数不但能描述期望还能把订单号、请求ID、相关上下文透传出来让失败现场还原度提高一大截。这里我踩过一次坑顺带说一句msg参数只在断言失败时才输出它不会干扰正常通过时的日志。所以放心大胆地加别担心日志刷屏。4.2 assertRaises捕获异常之后还要断言什么assertRaises是unittest里一个非常有用的断言用来验证某段代码是否抛出了预期异常with self.assertRaises(ValueError): self.service.process_amount(-1)这个写法的好处是只要抛出了ValueError不管具体错误消息是什么测试就通过了。但问题也在于此它只能验证异常类型验证不了异常里的信息。在真实业务里异常消息往往携带了关键的诊断信息。比如process_amount在处理负数时抛出ValueError我们期望的消息是金额不能为负数但如果代码bug把消息写成了金额格式错误这个断言依然能通过因为异常类型一样。这就是测试泄漏。更好的做法是用assertRaisesRegex它同时校验异常类型和消息中的正则匹配with self.assertRaisesRegex(ValueError, 金额不能为负数): self.service.process_amount(-1)或者用更灵活的写法先把异常对象捕获下来再单独断言with self.assertRaises(ValueError) as cm: self.service.process_amount(-1) self.assertEqual(cm.exception.code, NEGATIVE_AMOUNT) self.assertIn(金额不能为负数, str(cm.exception))这里cm.exception就是捕获到的异常实例你可以进一步验证它的属性、错误码、详情。我建议在涉及自定义异常类的场景里优先用这种写法它能锁定的信息远比assertRaises多。4.3 tearDown和addCleanup收拾现场不影响断言结果tearDown和断言看似不直接相关但如果你在tearDown里写错代码它会影响整个测试类的结果这是一个很隐蔽的坑。tearDown是在每个测试方法执行之后运行的哪怕测试方法本身断言失败tearDown依然会执行。如果你的tearDown里又抛了异常unittest会把这个异常作为新的错误信息覆盖掉原本的断言失败信息导致你看到的错误完全不是原始问题。我记得有一次跑测试一个用例报错了我点开日志发现错误是无法删除临时文件排查了半天最后才发现是断言失败后tearDown清理时出的问题原始断言失败信息被吞掉了。从那以后我给自己定了一条规矩tearDown里只做清理不做新断言不调用可能抛异常的逻辑。如果清理步骤存在不确定性用try/except包住或者用addCleanup。addCleanup是setUp之后注册的一个清理回调它有几点优于tearDown即使setUp中途抛异常addCleanup注册的清理函数依然会执行。可以注册多个清理函数按LIFO顺序执行。不需要定义一个完整的tearDown方法来承载所有清理逻辑。def setUp(self): self.temp_file tempfile.NamedTemporaryFile(deleteFalse) self.addCleanup(os.remove, self.temp_file.name)这种写法比tearDown更健壮它不需要关心setUp是否成功也能保证资源被回收。断言的结果不会被清理环节的异常干扰这是一条非常重要的实战经验。5. 自定义断言测试代码也需要自己的断言手册5.1 什么时候值得写自定义断言unittest内置的断言方法虽然丰富但面对一些业务场景它还是不够会说人话。比如我们有一个订单对象包含订单号、金额、状态、创建时间、商品列表等多个字段。每次断言这个订单合法时你都得写长长一串self.assertEqual(order.status, pending) self.assertEqual(order.total_amount, 500) self.assertGreater(len(order.items), 0) self.assertIsNotNone(order.created_at)这种断言逻辑如果在很多测试里重复出现就可以考虑封装成一个自定义断言方法把订单合法的定义收敛到一个地方。以后业务规则变了只需要改一个方法而不是全局搜索替换。要警惕的是另一个极端过度封装。如果自定义断言的内部逻辑比它要替代的代码还复杂那就失去了提升可读性的意义。我的判断标准是自定义断言至少要满足出现三次以上和逻辑稳定两个条件否则直接用内置断言更省事。5.2 一个真实的自定义断言实例我们项目里有一个用户余额账户对象测试里经常需要验证账户的流水记录。内置断言写起来又碎又长后来我们封装了一个自定义断言class AccountBaseTest(unittest.TestCase): def assertAccountTransactionList(self, account, expected_transactions): actual [ (t.type, t.amount, t.balance_after) for t in account.transactions ] self.assertEqual(actual, expected_transactions, msgf账户 {account.account_id} 流水不一致)这样在具体测试里只需要一行self.assertAccountTransactionList(account, [ (deposit, 1000, 1000), (withdraw, 300, 700), (deposit, 200, 900), ])这个自定义断言的本质是把对象转换成一个可比较的元组列表然后用assertEqual做比较。它保留了内置断言的全部优点清晰的差异信息同时把业务对象的比较逻辑集中到了一处。还有一个小技巧如果你自定义了断言方法记得把方法名以assert开头因为unittest的测试发现机制会尝试收集assert开头的方法作为断言辅助方法这在一些IDE和插件里能获得更好的支持。5.3 跳过、预期失败与断言的协作边界最后聊一个容易混淆的话题跳过、预期失败和断言失败三者之间的关系。跳过skipTest不是断言失败。它表示这个测试暂时不具备运行条件比如依赖的外部服务还没部署、某个平台专属功能在另一个平台不适用。跳过应该是在执行测试逻辑之前就做出的判断而不是在断言失败后为了让测试变绿而把断言改成跳过。预期失败expectedFailure则是明确标记这个用例目前是失败的但这是预期内的不影响整体构建。它的使用场景很特定比如你已知存在一个bug但短时间内无法修复需要把它记录下来。一旦你标记了expectedFailureunittest会把它作为一个独立的统计项而不是当成整体失败。在实际使用中这两个功能最容易被滥用的场景是测试在本地通过了但在CI上失败了于是有人给测试加上跳过条件只在本地跑。这种做法等于主动放弃了测试的价值还不如不写测试。跳过和预期失败永远是一种临时手段而不是长期方案一旦引入就应该同步创建跟进问题推动在短期内解决。自定义断言和这些机制协作时边界也很清晰断言方法负责验证业务结果是否正确跳过和预期失败负责管理当前已知但不是断言失败的情况。不要让自定义断言内部偷偷调用skipTest或者标记expectedFailure那样会让统计口径变得混乱。断言就是断言它应该诚实地反映测试结果。