我最早接触unittest,其实是带着一点抵触情绪的。那时候觉得写测试又要多写一倍代码,还挤占了开发时间,项目排期摆在那里,能跑起来就不错了。直到有一次上线前改了一个工具函数,自认为改动很小,结果把另一个模块的边界条件带崩了。回归测试靠人肉点,根本没点出那个分支。凌晨两点在工位上查日志查到怀疑人生,第二天一早就老老实实把测试补上了。后来我在好几个项目里全面铺开unittest,越用越觉得这玩意儿不是负担,而是在给你自己的代码上保险。这篇就把我实际怎么在项目里落地unittest的完整思路写出来,从基础框架到mock替身,再到工程化落地,全程是能直接抄作业的实战方案。
1. 基础框架与断言机制:先把测试跑起来
unittest是Python标准库自带的测试框架,不需要额外安装,import unittest就能用。它属于xUnit家族,和Java的JUnit、C#的NUnit一个路数。你只要写过其中任何一个,上手unittest几乎没有成本,因为核心概念是通用的:测试用例、测试套件、测试运行器。
1.1 最小的测试用例是怎么组织的
一个最基础的unittest测试用例,就是继承unittest.TestCase的类,里面定义一堆以test_开头的方法。类名和文件名本身没有硬性要求,但强烈建议按照被测试的模块来命名,比如测calc.py就写test_calc.py,放在tests/目录下。这样在一个中型项目里,找人找测试都方便。
import unittest class TestCalc(unittest.TestCase): def test_add(self): self.assertEqual(calc.add(2, 3), 5)test_开头这个约定非常关键,unittest会通过TestLoader自动识别类中以test开头的方法,并把它当作一个独立的测试点去执行。要是方法名改成check_add或者add_test,这个测试就会被静默跳过,你根本不知道它没有跑。我在项目里排查过好几次"测试全绿但心里发虚"的情况,最后都是这种命名问题。
1.2 断言方式远不止assertEqual
unittest提供了二十多个断言方法,常用的除了assertEqual,还有assertTrue、assertFalse、assertIn、assertIsNone、assertRaises、assertAlmostEqual。
self.assertIn("error", response) self.assertIsNone(user.name) with self.assertRaises(ValueError): calc.divide(1, 0) self.assertAlmostEqual(0.1 + 0.2, 0.3, places=7)为什么非要有一堆断言方法,直接用Python内置的assert不行吗?关键区别在于失败时的信息量。assertEqual(1, 2)失败时,unittest会明确告诉你期望值是1,实际值是2,一眼就能定位。而assert x == y只抛出一个AssertionError,具体两边是什么值,你还得自己打日志看。写测试本身就是为了少折腾,能一步给足信息的事,别省。
另一个细节是assertAlmostEqual。浮点数计算的精度问题,相信写过程序的都踩过。0.1加0.2不等于0.3,这在二进制浮点表示里是正常现象。所以只要涉及浮点比较,别用assertEqual,用带精度的断言,给自己留出容差空间。
1.3 从命令行运行测试
执行测试的方式,最开始可以直接用:
python -m unittest test_calc.py也可以让unittest自动发现项目里的所有测试:
python -m unittest discover -s tests -p "test_*.py"discover这个子命令会把指定目录下所有符合模式的文件都找出来执行。-s是起始目录,-p是文件名模式。这里有个细节,discover要想正常工作,测试文件所在目录需要有__init__.py文件,或者用-t参数指定顶级目录。这个问题在小型项目里不常见,但是随着项目目录结构变复杂,经常会出现"找不到模块"或者"没有可执行的测试"的情况。
测试运行器的输出也有讲究。默认是点号加反馈,再详细的可以用-v参数,每个测试用例的名字和方法名都会打印出来。我在排查问题时一般用-v跑,能一眼看出是哪个用例挂了。
2. 夹具机制与生命周期:为什么setUp和tearDown不能乱写
测试最怕的是用例之间互相影响。前一个用例改了全局数据,后一个用例拿到的状态就不对了。unittest通过夹具机制解决这个问题,也就是每个测试方法执行前后,框架自动调用特定的初始化、清理方法。
2.1 方法级夹具setUp/tearDown
每个测试方法执行之前,unittest都会先调用setUp,执行之后再调用tearDown。这样每个测试开始前,都是干净的环境:
class TestDatabase(unittest.TestCase): def setUp(self): self.conn = create_database_connection() self.conn.clean_all_tables() self.user_service = UserService(self.conn) def tearDown(self): self.conn.close()这块的逻辑是:不把环境恢复的逻辑放在测试方法内部,而是交给夹具统一管理。否则每个测试里都要写一遍初始化和清理的代码,一旦初始化方式变了,十多个测试文件都要改。
在实际项目里,我见过有的人在setUp里创建文件、连接数据库、启动服务,然后忘记在tearDown里关闭和删除。结果就是测试跑完,临时文件堆积,连接数暴涨。测试本身没问题,但是跑完整个项目环境脏了。所以tearDown不是可选项,只要在setUp里创建了资源,就必须配套清理。
2.2 类级夹具setUpClass/tearDownClass
方法级夹具的问题是,如果每个用例都要连接一次数据库,几百个用例跑下来,光是建立连接的时间都够喝一壶茶了。这种情况下就应该用类级夹具,整个测试类只初始化一次,供类内所有测试方法共享。
class TestDatabase(unittest.TestCase): @classmethod def setUpClass(cls): cls.conn = create_database_connection() cls.user_service = UserService(cls.conn) @classmethod def tearDownClass(cls): cls.conn.close()注意,类级夹具必须用@classmethod装饰器,方法的第一个参数是cls而不是self。而且类级夹具是共享的,如果一个测试方法把数据库某个表的数据改了,后面的测试方法看到的就是改过的数据。所以什么时候用类级夹具,要看被测试的东西是否允许共享状态。
我自己的原则是:数据库连接、外部服务客户端这种"建立起来很贵、用完不需要重置"的资源用类级;而像临时文件、内存数据结构这种"每个用例都需要独立初始状态"的东西,老老实实用方法级。
2.3 跳过测试与预期失败
unittest还提供了@unittest.skip、@unittest.skipIf和@unittest.expectedFailure几个装饰器。有些测试依赖外部环境,比如需要连一个测试环境数据库,但开发机没有权限;或者某个已知bug短期内没法修,测试写好了但就是跑不过。这时候用跳过机制,而不是把测试注释掉或者删掉。
@unittest.skip("数据库未部署,跳过集成测试") def test_user_sync(self): ... @unittest.skipIf(sys.platform == "win32", "该特性仅在Linux下支持") def test_file_permission(self): ... @unittest.expectedFailure def test_known_bug(self): # 已知问题,等修复后移除该装饰器 self.assertEqual(legacy_func(42), 100)expectedFailure这个装饰器很实用。它表示这个用例预期会失败,如果真失败了,测试报告里显示为"预期失败",整体不红;如果某天它意外通过了,unittest会标记为"unexpected success",提醒你该移除这个标记了。
3. Mock与测试替身:如何测试还没写好的外部依赖
单元测试的核心是隔离。我们要测的是当前模块的逻辑,而不是它的上下游。如果被测函数里调用了requests.get,每次测试都真的发起HTTP请求,那这个测试就不稳定了——网络稍慢一点就超时,接口改了数据格式就报错。而且这样测出来的结果,很大程度上是在测外部服务的稳定性,不是你的代码。
3.1 mock的基本用法
unittest.mock是Python 3.3起内置的mock库。它的核心思想是用一个假对象替换真实对象,并记录被调用的信息。
from unittest import mock import requests def fetch_data(url): resp = requests.get(url) return resp.json() class TestFetchData(unittest.TestCase): @mock.patch("module_name.requests.get") def test_fetch_data(self, mock_get): mock_resp = mock.Mock() mock_resp.json.return_value = {"status": "ok"} mock_get.return_value = mock_resp result = fetch_data("http://example.com/api") self.assertEqual(result["status"], "ok") mock_get.assert_called_once_with("http://example.com/api")这里有个特别容易踩的坑:@mock.patch的路径不是"被调用的库的路径",而是"被测模块里使用的库的路径"。如果fetch_data定义在myapp/api_client.py里,代码写的是from requests import get,那么patch的路径应该是myapp.api_client.get,而不是requests.get。原因在于当你执行from requests import get之后,myapp.api_client模块的全局命名空间里已经绑定了get这个变量,patchrequests.get根本影响不到它。
我最初写mock的时候,反复在这个问题上栽跟头。后来总结出一个口诀:凡是"被测模块里直接能看到的名字",就patch那个模块的路径。
3.2 mock的进阶用法
mock对象的return_value可以设置返回值,也可以设置多个返回值,支持迭代:
mock_obj.side_effect = [1, 2, 3] mock_obj.side_effect = ValueError("自定义异常")side_effect比return_value灵活得多,它可以是可迭代对象(每次调用依次返回对应值),也可以是异常(调用时抛出),甚至可以是函数(根据参数动态返回结果)。当一个被mock的方法需要根据入参不同返回不同结果,用函数作为side_effect最合适。
assert_called_once_with是常用的断言,但还有几个相关的也要会用:assert_called(只要被调用过就行)、assert_any_call(不关心顺序,只要出现过指定参数调用)、assert_not_called(确认没被调用过)。在验证调用次数的时候,我是在实现里经常用mock_obj.call_count直接看数字。
3.3 mock与上下文管理器配合
除了用装饰器,mock还可以用with语句,作用范围只限在with块内部:
def test_fetch_data(self): with mock.patch("myapp.api_client.get") as mock_get: mock_get.return_value.json.return_value = {"status": "ok"} result = fetch_data("http://example.com/api") # 到这里mock就自动还原了装饰器方式适合mock整个测试方法;上下文管理器方式适合只在某一段代码需要mock的场合。这个纯粹看个人习惯和使用场景,两种都可以,不存在谁更优的说法。
3.4 什么时候不应该用mock
mock确实好用,但不可滥用。如果被测函数里全是mock出来的对象,这个测试实际上没测任何逻辑,只是在确认"mock对象按预期被调用了"。我在代码评审里看到过一种测试,核心业务方法里五六个依赖全被mock了,然后断言这些mock对象都被调到了,这本质上是在测试mock库本身,没有任何价值。
还有个原则:不要mock你不拥有的代码。第三方库、框架提供的对象,在集成层面应该用真实对象去测,mock它们容易让你忽略版本升级带来的行为变化。自己写的模块之间,用mock隔离是合理的。
4. 工程化落地:拆分、子测试与覆盖率的组合拳
写了几个测试文件之后,你会发现测试代码也需要架构设计。一个文件几百行,一个类里十几个test_方法,跑到中间某个失败,后面的用例仍然会继续跑。排列组合的场景一多,测试方法数量会爆炸式增长。
4.1 子测试subTest
当一个功能有多种输入组合,每一种组合都值得验证时,不要写一堆几乎相同的测试方法,直接用subTest:
class TestStringOperations(unittest.TestCase): def test_split_by_separator(self): cases = [ ("a,b,c", ",", ["a", "b", "c"]), ("a|b|c", "|", ["a", "b", "c"]), ("", ",", [""]), ("single", ",", ["single"]), ] for input_str, sep, expected in cases: with self.subTest(input_str=input_str, sep=sep): self.assertEqual(split_string(input_str, sep), expected)subTest的好处是,循环里的每个case都被独立执行和报告。如果第三个case失败,第一个、第二个、第四个case的结果仍然会输出,不会因为中途异常而中断后续的验证。而如果不用subTest,一旦某个case断言失败,整个循环就停了,后面的case根本跑不到。
在执行结果里,每个子测试都有独立的标识,方便精确知道是哪个输入组合出了问题。这一点在数据驱动风格测试里特别实用。
4.2 按业务模块拆分测试文件
测试文件的结构,我倾向于跟源代码结构保持一致:
project/ ├── myapp/ │ ├── __init__.py │ ├── calc.py │ ├── user_service.py │ └── api_client.py └── tests/ ├── __init__.py ├── test_calc.py ├── test_user_service.py └── test_api_client.py这种结构的好处是,看到一个测试文件就知道它对应哪个模块。新人接手项目时,"哪里坏了去哪找测试,哪个测试挂了去哪找代码"这个映射非常直观。
tests/__init__.py这个文件不是可有可无的。unittest discover在递归扫描测试目录时,如果目录不是包,会碰到相对导入和模块命名解析的各种奇怪问题。加上这个文件,再用-t project_root -s tests指定顶级目录,基本不会踩到导入路径相关的坑。
4.3 test suite的灵活组合
discover可以自动发现所有测试,但有些场合你只希望跑特定的一个子集。比如只想跑和用户服务相关的测试,或者只想跑某个类里的某个方法。addTest和TestSuite的组合提供了这种灵活性:
import unittest from tests.test_calc import TestCalc from tests.test_user_service import TestUserService def suite(): suite = unittest.TestSuite() suite.addTest(TestCalc("test_add")) suite.addTest(unittest.makeSuite(TestUserService)) return suite if __name__ == "__main__": runner = unittest.TextTestRunner(verbosity=2) runner.run(suite())实际项目里我很少写这种suite文件,因为discover已经足够日常使用了。特殊场景确实需要,比如冒烟测试只需要跑核心几条用例,这时候自定义suite比写一堆skip装饰器干净多了。
4.4 覆盖率工具coverage.py
光有测试不等于高枕无忧。测试写了不少,但有没有覆盖到关键分支?用覆盖率工具,跑一遍测试的同时,统计哪些代码行被执行了,哪些分支没走到。
pip install coverage coverage run -m unittest discover -s tests coverage report -m coverage htmlcoverage report -m会列出每个文件的行覆盖率,Missing列会显示哪些行没被执行到。coverage html会生成一个静态网页报告,在浏览器里直接看哪些行是红的。
我自己的经验是,行覆盖率不需要追求100%,那不现实。但是核心业务模块至少要到80%以上,异常处理和边界条件分支一定要覆盖。覆盖率报告最大的价值不是那个百分比数字,而是那些红色的"Missing"行——它会告诉你,某些异常分支或者错误处理代码从来没被执行过,那里的bug极有可能一直潜伏着。
5. 常见问题与排查技巧实录
写测试踩过的坑,比业务代码踩过的坑一点不少。很多问题表面上看起来莫名其妙,实际上都是对unittest运行机制理解不到位导致的。
5.1 "我明明改了代码,测试结果怎么不变?"
这大概是新手最容易碰到的问题。排查思路很简单:先确认你跑的是不是最新的文件。Python的pyc缓存机制,加上编辑器有时没有自动保存,很容易让人产生错觉。后来我在CI脚本里固定加上先清理缓存再跑测试的命令:
find . -name "__pycache__" -type d -exec rm -rf {} +5.2 测试之间互相影响,单个跑全绿,一起跑就挂
这是典型的测试隔离没做好。问题往往出在:测试代码里用了模块级变量、类变量或者全局状态,前一个用例修改了,后一个用例还在用旧状态。
排查方法是用-v参数列出每个测试的执行顺序,然后在涉及共享资源的地方分别把setUp和setUpClass的执行顺序理清楚。我的建议是:不要依赖测试方法的执行顺序,每个用例都应该在setUp里构建自己需要的最小环境。
5.3 浮点数比较的一致性
assertAlmostEqual默认比较到小数点后7位,places=7。如果业务场景要求更高的精度,可以指定更大的places,但要注意浮点误差是累积的。与其增大精度,不如在业务代码里使用Decimal做精确计算,测试的时候直接比较Decimal对象,更干净。
5.4 patch路径不对导致mock没生效
前面提到过,patch的装饰器参数必须是"被测模块里能看到的名字"。有个快速验证的方法:在被测函数里加一行print(get),然后运行测试,看打印出来的是不是MagicMock对象。如果是Mock,说明patch生效了;如果打印的是函数自身的地址,说明路径配错了。
5.5 测试用例太多,跑一次太慢
如果一套测试从几秒涨到几分钟,就需要排查性能瓶颈了。常见的拖慢点:每个用例都创建新的数据库连接、请求了外部接口、执行了耗时很长的IO。这时候先用-v观察一下,是某几个用例特别慢,还是整体都慢。然后针对性地把外部调用替换成mock、把不必要的类级连接改成共享。
还有一个经验是合理设置setUp里的初始化数据量。有次发现跑了半天,后来定位到是因为setUp里向数据库批量插入了上千条产品数据来准备测试环境,实际只需要几条就够了,白白浪费大量时间。
5.6 测试报告的展示
默认的输出方式够用,但在CI系统里,我更推荐用unittest-xml-reporting生成XML格式的报告,Jenkins、GitLab CI都能直接解析并展示测试趋势。
pip install unittest-xml-reporting python -m xmlrunner discover -s tests -o build/reports每次提交代码后,CI自动跑测试,把XML报告归档,长期积累下来就能看到测试用例数量和通过率的变化趋势。这对团队项目尤其有用,谁提交的代码导致测试挂掉,报告里一目了然。
6. 集成到CI流程:让测试自动守护每次提交
测试的威力只有在自动运行的时候才能真正显现。如果每次都要手动在终端敲命令,总会有忘记跑测试的时候。
6.1 最小化配置的GItHub Actions工作流
以GitHub Actions为例,一个基本的Python测试流程,配置文件放在.github/workflows/test.yml:
name: Run Unit Tests on: push: branches: [ main ] pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install dependencies run: | pip install -r requirements-dev.txt - name: Run unittest run: | python -m unittest discover -s tests -v - name: Upload coverage report run: | coverage run -m unittest discover -s tests coverage xml - uses: actions/upload-artifact@v4 with: name: coverage-report path: coverage.xml这里的关键点是requirements-dev.txt,它是开发依赖列表,和生产的requirements.txt区分开。unittest是标准库不需要装,但coverage、unittest-xml-reporting这类工具,以及测试环境下需要的依赖都放在这里。
6.2 控制在合理的运行时长
CI的任务是快速反馈,如果测试跑太久,开发者的注意力会分散。一个经验是:把单元测试和集成测试分开,单元测试必须在几分钟内跑完,集成测试可以放到专门的流水线阶段。unittest本身没有区分这两者的机制,但你可以通过目录约定来实现:tests/unit/和tests/integration/,CI脚本里分别用不同的命令来跑。
python -m unittest discover -s tests/unit -v python -m unittest discover -s tests/integration -v有了这套目录约定,discover的-t参数要指向项目的顶级目录,否则跨目录导入会出问题。
6.3 遇上"全绿但上线出问题"
这种情况最让人恼火,问题一般不是测试写得不对,而是测试覆盖漏了关键路径。尤其是接口联调阶段,前端传的参数类型和后端定义的不一致,单元测试因为用了mock,完全没暴露这个问题。单元测试永远代替不了联调,它的作用是保证单点逻辑正确,而链路问题要靠集成测试和人工验证来兜底。
所以我的态度是:单元测试是质量的底线,不是全部的保障。写测试的时候,重点覆盖自己写的业务逻辑、分支判断和异常处理,而不是追求把所有依赖全都mock掉。
写unittest这件事,越往后越会觉得它其实是在培养一种代码思维——写代码的时候自然会考虑它好不好测,依赖关系是不是清晰,状态管理是不是可控。这些反过来会让业务代码更干净。我现在接到一个模块的活,会先跟产品对清楚业务规则,然后列出需要覆盖的边界条件,最后才开始写实现。等代码写完了,测试也已经跟着写完了,并不是额外多出来的一件事。这套工作流花了些时间适应,但带来的稳定感,是以前"写完就跑、跑完就上线"的方式完全没法比的。