1. 为什么你的项目需要单元测试——先想清楚再动手
1.1 单元测试到底在解决什么问题
我在一线写了十来年代码,见过太多项目死在"改一处代码,崩三个功能"的泥潭里。最常见的场景是:产品经理说"帮我把价格计算里加个折扣",你花了十分钟改完,自我感觉良好,结果上线后运维打电话过来说老用户的订单金额全错了。这锅谁背?归根结底是没人敢保证改动不影响原有逻辑。
Python作为动态语言,没有编译期检查,函数参数传错类型、返回结果变了个结构、某个边界条件没处理,这些问题在运行时才会暴露。unittest的价值就在于把"永远没问题"这种模糊信心,变成"每次改动都能跑一遍的自动化验证"。它解决的核心痛点是回归风险——不是测你写的新功能,而是保护你以前写的那些还能用的老功能。
单元测试适合谁?如果你是自己写小工具的个人开发者,可能觉得用不到;但只要你的代码要给别人用、要长期维护、要频繁加需求,单元测试就是必需品。尤其做爬虫、数据处理、API接口这类项目,数据格式一变,代码就崩,没有测试直接裸奔,排查起来想死的心的都有。好在我今天讲的这套unittest用法,不挑项目类型,通用性极强。
1.2 什么时候写、什么时候不该写——一个务实的判断标准
很多人一听"测试先行"就头大,觉得是额外工作量。我的判断标准很朴素:这个函数有没有"依赖特定输入、产生明确输出"的逻辑?只要有,就值得写测试。比如价格计算、字符串格式化、JSON解析、状态判断、日期转换,全是重灾区。
反过来,UI界面、外部服务调用、复杂分布式流程这类,不适合用单元测试硬扛,应该用集成测试去覆盖。我见过有同事硬给Flask的整个请求流程写单测,mock了数据库又mock了缓存,最后测了个寂寞——断言全是通过的,代码全是假的,真正的bug一个都没抓住。单元测试要测的是"函数本身的行为",不是"框架帮你做了什么"。
还有一个务实的建议:不要追求100%覆盖率。我写过覆盖率90%以上的代码,后期维护成本极高,因为一半的测试是在测防御性代码(比如except里那段根本走不到的return)。能覆盖核心业务逻辑、能拦住历史bug回归,就够了。覆盖率80%左右是我个人比较舒服的状态。
2. unittest框架核心机制拆解
2.1 TestCase的结构和执行顺序
unittest是Python标准库自带的测试框架,不需要装任何第三方包,import unittest就能用。它的最小单位是TestCase类,每个以test_开头的方法就是一个测试用例。
import unittest class TestDemo(unittest.TestCase): def setUp(self): # 每个测试方法执行前都会跑这个方法 self.data = {"name": "张三", "age": 18} def tearDown(self): # 每个测试方法执行后都会跑这个方法 self.data.clear() def test_name_exists(self): self.assertIn("name", self.data) def test_age_positive(self): self.assertGreater(self.data["age"], 0)我重点说下执行顺序:setUp -> test_xxx -> tearDown,每个测试方法独立跑一遍这个循环。为什么要设计setUp?因为测试用例之间必须隔离,第一个用例改了self.data,第二个用例不能再受影响。这种隔离性是单元测试能稳定给出结论的前提。
类级别的setUpClass和tearDownClass很多人忽略,其实非常有用:
class TestDemo(unittest.TestCase): @classmethod def setUpClass(cls): # 整个类跑之前只执行一次 cls.conn = create_database_connection() @classmethod def tearDownClass(cls): cls.conn.close()适合放那些开销大、又不会污染用例之间状态的东西,比如数据库连接、临时文件目录。要是放setUp里,100个测试建100个连接,跑一次测试十分钟,典型的没苦硬吃。
2.2 断言方法大全与选择思路
unittest的断言方法看着多,核心思路其实只有两类:判断结果对不对,判断抛不抛异常。我把实际用得最多的列一张表:
| 断言方法 | 作用 | 典型场景 |
|---|---|---|
| assertEqual(a, b) | 判断相等 | 计算函数返回结果 |
| assertNotEqual(a, b) | 判断不相等 | 排除特定值 |
| assertTrue / assertFalse | 布尔判断 | 状态开关、权限校验 |
| assertIsNone / assertIsNotNone | 判空 | 查询结果是否存在 |
| assertIn / assertNotIn | 成员判断 | 列表、字典键、字符串子串 |
| assertAlmostEqual(a, b, places=2) | 浮点数精度比较 | 金额、概率、比例计算 |
| assertRaises(SomeException) | 断言抛出异常 | 参数校验、越界处理 |
| assertCountEqual(a, b) | 列表内容相等忽略顺序 | 排序结果、集合操作 |
浮点数比较是新手最容易踩的坑。0.1 + 0.2 == 0.3在Python里结果是False,因为浮点数二进制存储本来就有精度损失。你要敢用assertEqual去比两个浮点数,测试大概率飘红。这时候用assertAlmostEqual,或者自己写个误差范围判断:
def test_price_calculation(self): total = calculate_total(100, 0.075) # 100块加7.5%税 self.assertAlmostEqual(total, 107.5, places=2)断言异常的标准写法用with上下文:
def test_invalid_input(self): with self.assertRaises(ValueError): parse_age("abc") # 非法输入必须抛ValueError这样既验证了抛异常,又不会让异常真的中断测试流程。我见过有人用try/except自己接异常再断言,绕了一大圈,代码还容易出岔子,完全没有必要。
2.3 mock与patch:隔离外部依赖的正确姿势
单元测试有个铁律:不能让测试结果受外部环境影响。你的函数调了个第三方API,网络超时算谁的错?连了数据库但库里数据变了,测试挂了算谁的错?都不该算你函数的错。这时候就需要mock——把外部依赖换成假的。
unittest.mock是Python 3.3之后内置的模块,核心用法是patch:
from unittest.mock import patch class TestOrderService(unittest.TestCase): @patch("my_project.services.payment_gateway") def test_create_order_success(self, mock_payment): mock_payment.charge.return_value = {"status": "success"} order = create_order(...) self.assertEqual(order.status, "paid")@patch装饰器会把my_project.services路径下的payment_gateway换成MagicMock对象,后面测试里调用的charge方法不执行任何真实逻辑,直接返回你指定的值。这里最关键的是patch的路径必须指向"模块实际加载的位置",不是"定义的位置"。比如你在a.py里from b import pay,那要patch的是a.pay,而不是b.pay,因为a模块的命名空间里已经有这个引用了。
mock还有个常用方法assert_called_once_with,验证函数是否按预期参数被调用:
def test_should_send_notification(self, mock_notifier): service = OrderService() service.create_order(...) mock_notifier.send.assert_called_once_with( user_id=123, message="订单创建成功" )这个断言特别适合用来验证"某个合作方方法有没有被正确调用",比关心返回值更贴近真实业务。
3. 手把手搭建一个可维护的测试体系
3.1 测试目录结构设计与运行方式
搞清楚了框架基本用法,真正实战起来还要解决"测试代码放哪"的问题。我的推荐是项目根目录下建tests/文件夹,结构长这样:
my_project/ ├── my_project/ │ ├── __init__.py │ ├── services.py │ └── utils.py ├── tests/ │ ├── __init__.py │ ├── test_services.py │ └── test_utils.py └── run_tests.py为什么要单独建目录?因为把测试文件和源码混在一起,__init__.py会互相干扰,而且很多人会不小心在部署的时候把测试代码一起带上,没必要。tests/__init__.py也不能省,否则部分IDE和工具在导入测试模块时会出问题。
运行方式我推荐两种。第一种是命令行直接指定文件:
python -m unittest tests/test_services.py-m unittest和直接跑python test_services.py有个差别:前者会正确导入并发现所有继承TestCase的类,后者需要文件末尾自己加unittest.main()。我建议统一用前一种。
第二种是全量发现:
python -m unittest discover -s tests -v-s tests指定扫描目录,-v输出详细信息。注意discover的默认匹配规则是test*.py,所以测试文件命名必须以test开头,否则扫不到。
3.2 用setUpClass/tearDownClass管理共享资源
我最早写测试的时候,每个用例里都写一遍数据准备代码,结果测试代码比业务代码还长,维护成本直线上升。后来学会用setUp做公共准备,但那还不够——有些开销大的资源比如数据库连接、临时目录、外部服务模拟器,每个用例都重连一遍,100个用例就要重连100次,慢到怀疑人生。
setUpClass就是干这个的。我给个真实案例:当时给一个数据处理模块写测试,模块启动时要加载一份5万行的参考数据,加载一次要3秒。不用setUpClass的话,50个测试用例光加载数据就要150秒,谁等得住?改成setUpClass只加载一次,整个测试文件跑完也就5秒。
class TestDataProcessor(unittest.TestCase): @classmethod def setUpClass(cls): cls.reference_data = load_reference_data() cls.processor = DataProcessor(cls.reference_data)但这事有个前提:共享资源必须是只读的,或者不可变的。一旦某个用例修改了共享数据,其他用例跑的时候就会拿到被污染的数据,测试结果就开始随机飘。所以我后来的习惯是:连接、大对象这类"读了不改"的资源放setUpClass,需要隔离的临时状态放setUp,个人状态在两个用例中间变化就各自重建。
3.3 数据驱动测试用subTest
很多测试的本质是"同一条逻辑,不同输入,验证不同输出"。过去的标准写法是在一个测试方法里写多个断言,比如:
def test_calculate_discount(self): self.assertEqual(calculate_discount(100, "VIP"), 80) self.assertEqual(calculate_discount(100, "NORMAL"), 90) self.assertEqual(calculate_discount(0, "VIP"), 0)问题在于:第一个断言挂了,后面的断言压根不会执行,你没法一眼看出后面几个场景是对是错。subTest就是解决这个问题的:
def test_calculate_discount_all_cases(self): cases = [ (100, "VIP", 80), (100, "NORMAL", 90), (0, "VIP", 0), (50, "WHATEVER", 50), # 未知等级不打折 ] for price, level, expected in cases: with self.subTest(price=price, level=level): self.assertEqual(calculate_discount(price, level), expected)subTest的好处是:每个输入单独执行、单独报告结果。其中一个case挂了,不影响其他case的断言执行,跑完就能看到"哪些输入对,哪些输入错",排查效率高一大截。我在写边界值测试时基本都会套subTest,比如日期函数测试,2月有没有29号、大小月、闰年平年这些场景全塞进去,一次跑完所有组合。
3.4 测试覆盖率怎么用不闹心
覆盖率工具我用的是coverage.py,配合unittest跑一下就能出报告:
pip install coverage coverage run -m unittest discover -s tests coverage report -m报告会显示每个文件的行覆盖率,-m参数把没跑到的行也打出来。但这个工具看就行,不用太当真。我见过团队把覆盖率当作绩效指标,逼着开发写一大堆冗余测试,覆盖率上去了,bug也没少出。
覆盖率真正有用的场景是:当你准备重构代码的时候,跑一下覆盖率,看到哪些分支还没被测过,心里就有谱了。重构前把这些分支补上测试,重构时就不会有心理负担。我现在都是这么干的:功能做完,跑一次覆盖率,没盖到的关键分支补一两个用例,完事。别追求100%,愿意补齐核心分支就够了。
4. 常见问题排查与避坑实录
4.1 setUp里做了一堆无关操作导致测试变慢
这是最常踩的坑。有些人图方便,把所有初始化全塞setUp里,每个用例都跑一遍,数据库建表、外部服务启动、大文件加载,结果跑一次测试要十分钟。排查方法很简单:用python -m unittest -v看每个用例的耗时,慢的一眼就能看出来。
我的做法是分级处理:纯CPU计算类的测试,setUp只创建输入数据;涉及IO的测试,能mock就mock,不能mock再用setUpClass共享连接;还要更重的资源,比如启动一个临时HTTP服务,那就用setUpModule模块级别的初始化,整个测试文件只启动一次。
4.2 patch的路径写错导致mock没生效
mock没生效的表现是测试里那行调用了真实的外部服务,要么慢到死,要么直接被网络墙住。查下来十有八九是patch路径写错了。重要的事我再说一遍:patch路径取决于"被测代码里import进来后所在的位置",不是你mock对象定义的位置。
from my_project.services import PaymentGateway class OrderService: def __init__(self): self.gateway = PaymentGateway()这种写在__init__里的依赖,直接patch类不太容易生效,我一般改用依赖注入:
class OrderService: def __init__(self, gateway): self.gateway = gateway测试的时候塞个MagicMock进去:
def test_order(self): mock_gateway = MagicMock() svc = OrderService(mock_gateway) svc.create_order(...) mock_gateway.charge.assert_called_once()这种调整不仅让测试好写,业务代码的可读性、可替换性也提高了,实战里非常推荐。
4.3 断言粒度太粗,测试全绿但全是漏网之鱼
我见过最典型的错误断言是只断言了返回结果"不为空",或者"等于true"。比如测试一个解析函数:
def test_parse_json(self): result = parse_json('{"name": "test"}') self.assertTrue(result) # 太粗了,什么都验证不了真正该断言的是"解析结果里的具体字段值"。另外还有一类断言过细的问题,比如把一个列表转JSON字符串,断言整个字符串等于预期。结果列表顺序变一下、空格多了一个,测试就挂。这类用assertCountEqual或者解析完JSON再逐字段比较,维护成本低得多。
4.4 测试之间的隐式依赖
有些人写测试,用例A里面往某个文件写了数据,用例B里面读这个文件去断言,运行顺序一乱就挂。虽然unittest默认按字母序执行,但你能保证哪天没人改方法名吗?真正靠得住的设计是:每个测试自己准备数据、自己清理数据,不依赖其他用例的执行结果。tearDown里面清干净,是对团队其他成员最大的善意。
我写过一个检查清单,每次写完测试都会过一遍:
- 这个测试单独跑,能通过吗?
- 这个测试把顺序打乱,还能通过吗?
- 外部服务挂了,这个测试还会受影响吗?
- 测试代码本身有没有调用真实网络、真实文件系统?
全过才算合格。这套标准执行下来,我后来基本没被"测试莫名其妙挂掉"这种问题坑过。
5. 把单元测试跑进日常开发流程
5.1 每次改动跑一次——消费升级的底线
单元测试写好了不跑,等于白写。我给自己定的规矩是:每完成一个函数或一次重构,就立刻跑一遍该模块的测试文件,确保没把老的逻辑改坏。日常用一条命令:
python -m unittest tests.test_services -v跑完有红就改,改完全绿再提交。commit hook里也可以挂个测试命令,Python端用pre-commit配个命令,推送代码前自动跑一遍全量测试,有红就没法提交。团队协作时这招尤其管用——谁也别想把坏代码推上去。
5.2 和CI/CD结合的关键配置
如果项目托管在GitLab或GitHub上,建议在CI里加一道门禁:每次合并前跑全量测试,跑不过就不让合并。配置很简单,GitLab的.gitlab-ci.yml核心就几步:
test: script: - pip install -r requirements.txt - python -m unittest discover -s tests -v流程上我是这样设计的:本地开发跑单模块测试,提交时跑全量,CI里再跑一次全量+覆盖率统计。三层保障下来,我后来基本不担心"部署上去才发现改了结构"这种问题了,因为第一层就能拦下来。
5.3 我来斗胆吐槽两句
最后说点掏心窝的话。unittest官方文档写得不算好,全是API罗列,没有真实场景指引,新手很容易看晕。但是标准库的好处是零依赖、跨环境稳定,而且和pytest的兼容程度很高,你写的unittest用例pytest也能直接跑。所以就算团队后来切换到pytest,老代码也不用重写。
我个人的建议是:先老老实实把unittest学透,再去碰pytest。理解了TestCase、setUp、mock这套核心概念,pytest那些fixture、parametrize装饰器上手会非常快,因为你已经知道它们替代的是什么问题。
另外安利一个小技巧:测试代码里的函数名和变量名,尽量和业务代码保持一致。比如业务函数叫calculate_total_price,测试方法就叫test_calculate_total_price,看报告的时候一眼就知道是哪个功能挂了,不用到处翻代码,排查时间省下来是真的香。