
1. 开场一次改一行代码线上炸了的惨痛经历先讲个真实的事。去年我维护一个内部工单系统有个函数负责把用户提交的订单金额格式化后写入数据库逻辑很简单就是把字符串转成数字再保留两位小数。某天产品说金额后面加个货币符号我心想这还不简单顺手改了主流程里一行代码。结果上线当天就收到告警一批测试订单的金额全部变成了NaN因为有个用户在前端输入了带千分位逗号的金额刚好绕过了我改动的那条处理分支。复盘的时候团队里没一个人说话的——因为项目五年没人写过一条单元测试全靠人工点页面验证。这个教训很贵但也很典型没有自动化测试保护的代码改动一次就是一次赌博。于是我开始在团队里推Python单元测试用的是标准库自带的unittest。今天这篇就手把手把这一整套玩法写清楚给同样吃过亏的朋友一个参考。无论你是刚入门Python想建立测试意识还是已经写了几年业务代码想给老项目补测试这篇文章都适合你。2. 为什么要用unittestPython单元测试的基石与选型逻辑聊技术之前先回答一个大家心里都有的问题Python测试框架那么多pytest、nose2、doctest都在那摆着为什么我非要用unittest2.1 unittest在Python生态中的定位unittest是Python标准库自带的测试框架不需要任何额外安装。这意味着你新建一个.py文件、import unittest、写一个继承TestCase的类立刻就能跑测试。它源自Java的JUnit所以如果以前写过Java看unittest的代码会觉得特别亲切——setUp、tearDown、断言方法这套东西几乎是照搬过来的。我在团队里推广unittest最根本的原因是零依赖、零安装成本。公司有些老项目的运行环境还是Python 3.6内部pip源又不全装pytest还得申请网络权限。unittest直接就能跑CI流水线哪怕是最简配的也全都能支持。另外unittest的用例组织靠的是目录递归发现测试文件按test_*.py命名规则放进tests目录一条命令全跑这个对工程化管理非常友好。2.2 那么pytest更好用为什么不直接上pytest说实话如果是在个人项目或者全新项目里pytest确实更爽——它支持fixture、参数化、插件生态丰富断言不需要写self.assertEqual直接assert就行。但真实工作场景有个现实因素老代码改造。很多项目里已经有若干个基于unittest写的测试类直接推pytest可能面临兼容和迁移成本而这个成本在紧急业务面前没人愿意付。更稳健的策略是新项目或者新模块的测试可以考虑pytest这类更现代的框架存量代码的测试维护继续用unittest打底完全够用。而且unittest.base.TestCase类同样能被pytest的插件发现和运行两者并非水火不容。很多企业级项目都是先有unittest跑着后来陆续追加pytest共存期很常见。提示如果你在一家公司从零开始建测试体系我不建议一上来就纠结框架。先把unittest这套机制吃透理解测试夹具断言测试套件这些基本概念换到任何框架都是无缝的。个人经验是框架只是工具测试的思维才是核心——而unittest恰恰是用最原始的方式把这个思维教给你的。3. unittest核心机制拆解TestCase、断言与Fixture既然要实战就不能只停留在继承一个TestCase然后写test开头的函数这种表面认识。这三个概念我建议一定要彻底搞懂。3.1 TestCase与测试方法unittest的基本单元叫TestCase日常做法是继承unittest.TestCase。import unittest class TestStringMethods(unittest.TestCase): def test_upper(self): self.assertEqual(foo.upper(), FOO) def test_isupper(self): self.assertTrue(FOO.isupper()) self.assertFalse(Foo.isupper()) if __name__ __main__: unittest.main()这里面有个关键约定测试方法必须以test开头否则unittest不会执行它。这不是装饰器能补救的是框架的硬性约定。如果不小心把它命名成check_upper测试会直接静默跳过看起来全绿实际上跑了个寂寞。这里建议大家在代码评审的时候养成的习惯是凡是测试文件里以非test开头的函数都会问一句这是辅助函数还是被测用例如果是辅助函数为什么不用私有方法这能避免大批无效测试。3.2 断言方法检查结果的正确姿势unittest的断言方法特别多最初学的时候只需要记几个重要的。断言方法作用assertEqual(a, b)检查a bassertTrue(x)/assertFalse(x)检查x是否为真/假assertIsNone(x)检查x是否为NoneassertIn(a, b)检查a是否在b中assertRaises(SomeError)检查是否抛出指定异常assertAlmostEqual(a, b, places7)检查浮点数是否近似相等用的最多的延展技巧是在检查列表、字典这类复合结构时首先会想到用assertEqual直接对比整个对象——unittest会自动调用容器内部的元素比较很方便。之前在做接口返回结果校验时直接assertEqual(resp.json(), expected_dict)比手动遍历字段一份份断言省太多事。浮点数断言是个容易踩坑的点因为它涉及精度问题。比如你算了个0.1 0.2直接assertEqual会失败。正确做法是self.assertAlmostEqual(0.1 0.2, 0.3, places7)3.3 setUp与tearDown测试的前奏与收尾setUp和tearDown这对方法是非常实用的。假设你要测一个数据库操作类每个测试用例开始前都要初始化数据库连接结速后都得清理脏数据。把这些重复工作写进setUp和tearDown里就能避免每个用例都复制一遍初始化和清理逻辑。class TestDatabase(unittest.TestCase): def setUp(self): self.db Database() self.db.connect() self.test_user create_test_user() def tearDown(self): self.db.cleanup_test_data() self.db.close() def test_insert_user(self): self.db.insert(self.test_user) result self.db.get_user(self.test_user.id) self.assertEqual(result.name, test_user)这里要说一个细节setUp在每个测试方法执行前都会跑一次tearDown同理。很多人误以为setUp只会跑一次这是大坑。如果你的测试用例之间需要共用很重的资源比如启动一次浏览器应该去了解setUpClass和tearDownClass这对类级别的夹具用法是加classmethod装饰器。4. 实战从零写一个可落地的单元测试用例概念讲完了接下来看一个能直接抄作业的完整例子。我自己做过一个用户积分计算模块需求是这样用户下单成功后根据订单金额计算积分每消费10元产生1积分不满10的部分不积分如果订单有优惠券抵扣那抵扣后的金额参与计算积分不足没法扣为负数。这是个很适合写测试的业务逻辑。4.1 待测代码与设计思路先看被测代码# points.py def calculate_points(amount, discount0): 根据订单金额计算积分 :param amount: 订单原价金额单位元 :param discount: 优惠券抵扣金额单位元 :return: 返回整数积分最低为0 actual_amount amount - discount if actual_amount 0: return 0 points int(actual_amount // 10) return points这段逻辑很简单但恰恰是这种简单的模块最容易出问题——边界情况特别多。我只列几个要测的场景正常金额100元无抵扣返回10积分不满10元9元返回0折扣后恰好是整数100减2080返回8抵扣后金额为负100减200返回0抵扣和金额都是浮点数99.9减0.999返回9把这些写进测试文件import unittest from points import calculate_points class TestCalculatePoints(unittest.TestCase): def test_normal_amount(self): self.assertEqual(calculate_points(100), 10) def test_amount_less_than_10(self): self.assertEqual(calculate_points(9), 0) def test_with_discount(self): self.assertEqual(calculate_points(100, 20), 8) def test_discount_exceeds_amount(self): self.assertEqual(calculate_points(100, 200), 0) def test_float_operation(self): self.assertEqual(calculate_points(99.9, 0.9), 9) if __name__ __main__: unittest.main()这段测试跑出来的结果是全绿的但不会就此打住。关键点在于写完这些用例后我心里是有数的这个模块有五个典型行为每条路径都用确定的输入和预期输出锁死了将来谁再改这个函数只要不是主观想破坏行为测试就会保护我。4.2 运行测试的三种方式运行单测的方式有好几种根据场景去选。第一种最简单在文件底部用unittest.main()执行python test_points.py第二种不跑单个文件而是跑整个目录里的所有测试。假设所有测试文件都在项目根目录下的tests文件夹里执行python -m unittest discover tests这个命令会递归查找当前路径下所有test*.py的文件把它们当作测试来运行。第三种在CI里用只挑出某个模块的某个具体用例来定位问题python -m unittest tests.test_points.TestCalculatePoints.test_normal_amount从路径到类到方法逐层定位这在排查老旧项目时非常好用。4.3 运行结果怎么读单测跑完会有三种结果点号.表示测试通过字母F表示断言失败字母E表示代码执行过程中抛出了异常。下面是输出示例..... ---------------------------------------------------------------------- Ran 5 tests in 0.000s OK如果某个用例挂掉输出里会明确告诉你哪个断言不满足期望值是多少实际值又是多少。FAIL: test_discount_exceeds_amount (tests.test_points.TestCalculatePoints) ---------------------------------------------------------------------- AssertionError: 0 ! -10看到这个就知道实际返回了-10但期望是0。这道题出问题的地方在actual_amount的负数处理上我们代码是if actual_amount 0: return 0但如果不小心写成了return actual_amount // 10整数除法会把负数截成更负的整数值此时测试就会报这种错。这种反馈速度远比你上线后再被用户发现要快得多。5. Mock测试当被测函数变得身不由己时单元测试有个铁律测试不依赖外部环境。比如你测一个发短信接口总不能每次都真的发短信出去测一个调第三方API拿用户信息的业务函数更不能真的请求线上服务。这种时候就需要用mock技术让被测函数在测试环境里调用一个假的依赖。5.1 unittest.mock基础用法unittest.mock库是从Python 3.3开始内置的不需要装东西。最简单的用法是mock.patch装饰器from unittest import mock import requests def fetch_user_info(user_id): resp requests.get(fhttps://api.example.com/users/{user_id}) return resp.json() mock.patch(requests.get) def test_fetch_user_info(mock_get): mock_get.return_value.json.return_value {name: 张三, id: 1} result fetch_user_info(1) self.assertEqual(result[name], 张三) mock_get.assert_called_once_with(https://api.example.com/users/1)这里的核心逻辑是把requests.get这个属性路径替换成一个虚拟对象虚拟对象调用后的返回值你说了算然后断言真的调用了并且参数正确。这样你的测试没有真正发任何网络请求却能验证业务逻辑。这在做数据处理、适配器这类模块的测试时几乎是必备技能。5.2 避开Mock的三大幻觉mock虽好但有三个误区非常常见我跟同事反复讲过。第一个是mock错了路径。比如在fetch_user_info所在的模块里如果你用import requests引进来mock.patch(requests.get)是有效的但如果你用了from requests import get这种方式函数内部指向的是get这个局部名得mock模块名.get而不是requests.get。路径不对mock就不生效测试最后还是发了真实请求。第二个是mock过了头。有些新手会把被测函数自身也mock掉测试等于测了个寂寞。mock的目标应该是外部依赖不是被测逻辑本身。第三个是mock忘了断言调用次数。如果你只设置了return_value但忘了assert_called_once那等于只验证了结果正确没验证确实是走了这个依赖将来依赖没被调用而你的函数恰好返回值碰巧正确测试照样绿但行为已经悄悄变了。加上调用断言才是完整的。注意mock了外部接口之后测试结果是假的不代表线上接口真的返回这个值。所以在集成测试阶段要单独准备真实环境的验证mock主要用于单元测试阶段两者职责不同。5.3 side_effect模拟异常、序列返回值和动态响应mock还有一个强大的参数叫side_effect。它有三种玩法传一个异常类或异常实例调用mock对象时抛出这个异常用于测试异常分支传一个列表mock每次调用依次返回列表里的值用于模拟第一次请求失败、第二次重试成功传一个函数每次调用会动态计算返回结果举个重试的例子from unittest import mock mock.patch(module.third_party_call) def test_retry_logic(mock_call): mock_call.side_effect [requests_exception(), {success: True}] result module.do_with_retry() self.assertTrue(result) self.assertEqual(mock_call.call_count, 2)这个例子模拟了第一次调用第三方接口抛异常、第二次调用返回成功验证了重试逻辑确实生效。用这种技巧能把异常分支、超时分支、重试队列分支全都测到而不需要真正等超时。6. 常见问题与排查技巧实录测试写得再多总会有一些状况发生。这里把实操踩过的坑做个清单整理出来随查随用。6.1 测试之间互相影响数据串台痛点场景一个测试往数据库里插入了一条记录另一个测试查总数时莫名其妙多了几条。原因就是setUp或者测试方法里产生的数据没有在tearDown里清理干净。避开办法测试数据要独立、可清理。如果是数据库相关测试建议用事务回滚或独立测试库如果是文件缓存用完立刻删如果是全局变量记得在setUp里重置到默认值。在setUp里重置一次全局状态是比在tearDown里清理更稳妥的做法因为setUp永远会执行而tearDown万一你忘了问题就会污染下一个用例。6.2 明明写了测试为什么全绿因为用例压根没被发现这种情况多半是命名问题。测试文件名不是test_开头或者测试方法不是test开头或者测试类不继承unittest.TestCase三样任意缺一个unittest都不会运行它。最扎心的是你跑完看到Ran 0 tests还很开心以为是全过了。排查方法执行的时候加-v参数。python -m unittest -v tests/它会逐个列出每个测试用例的名称一目了然哪些被收集、哪些没有。6.3 浮点断言永远不过前面提过这是经典问题。assertEqual(0.1 0.2, 0.3)在Python里是False因为二进制浮点数没法精确表达0.3。解决方案是用assertAlmostEqual或者在比较前统一用round转成指定小数位。6.4 测试依赖特定的运行路径当你这么写测试时就是在埋雷with open(data/test.txt) as f: ...普通情况下没问题但一旦测试从别的目录运行比如CI在/opt/ci/project里执行而开发者在~/workspace/project里执行相对路径就乱了。稳妥方案是基于测试文件自身所在目录拼出文件的绝对路径import os BASE_DIR os.path.dirname(os.path.abspath(__file__)) file_path os.path.join(BASE_DIR, data, test.txt)这样无论从哪里发起测试都能找到那个文件。6.5 mock不生效import方式惹的祸前面讲mock路径时已经点过这个雷。再补充一点如果被测模块里是from requests import get你要mock的就是module_under_test.get而不是requests.get。判断依据是mock的是被测模块直接用到的那个名字。我遇到同事排查mock不生效最后发现他mock了requests.get而函数内部是from requests import get一个是requests模块里的属性一个是函数显式导入的本地名完全不搭边。7. 从写测试到建体系我的实际操作体会单测这件事代码写多了以后才能真正明白光会写Test开头的函数还远远不够测试的可维护性、可扩展性才是最重要的。如果测试代码写得又长又乱比业务代码还难读那就没人愿意维护它久而久之测试就腐烂了。我个人的经验是从一开始就应该给项目定好测试目录结构。一个典型的结构大概长这样project/ ├── app/ │ ├── __init__.py │ └── models.py ├── tests/ │ ├── __init__.py │ ├── test_models.py │ └── test_services.py └── run_tests.pytests目录下建__init__.py是为了让unittest能把它当成一个包来发现测试文件不写这个文件在有些版本的Python下会被跳过。run_tests.py里加一行unittest.main(moduleNone, argv[x, discover])或者干脆按官方推荐在Makefile里写test:目标那么一条命令就能把整个测试全跑起来。还要考虑测试数据的组织。我见过很多团队把期望值、测试数据、输入样例全部堆在测试代码里每写一个用例就复制一份。更好的做法是让测试数据和被测逻辑解耦输入输出做成像下面的样子cases [ (100, 0, 10), (99, 0, 9), (10, 5, 0), ] def test_calculate_points_many_cases(self): for amount, discount, expected in cases: with self.subTest(amountamount, discountdiscount): self.assertEqual(calculate_points(amount, discount), expected)这里用subTest而不是拆成多个独立用例好处是如果其中某个case挂了其他case依然会执行并报告你能一次性看到所有输入输出不匹配的地方而不是修一个挂一个、反复跑N趟。最后再提一个团队层面的建议把改完代码必须跑单测这个动作硬塞到提交流程里不要靠自觉。比如在Git的pre-commit钩子或者CI流水线里加一条python -m unittest discover跑不过直接打回。这几分钟的成本省的可能是几小时的回归排查时间。从那次崩线上事故到现在我已经在新老项目里补了上千条单元测试。回头看看改变最大的其实不是测试数量而是写代码时的心态——每次改一个函数心里都知道有一张安全网接住。这种踏实感是自动化测试带给一个程序员的隐形红利。