尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python unittest实战:从基础断言到Mock与CI集成

Python unittest实战:从基础断言到Mock与CI集成 我最早接触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 断言方式远不止assertEqualunittest提供了二十多个断言方法常用的除了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, places7)为什么非要有一堆断言方法直接用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_*.pydiscover这个子命令会把指定目录下所有符合模式的文件都找出来执行。-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 什么时候不应该用mockmock确实好用但不可滥用。如果被测函数里全是mock出来的对象这个测试实际上没测任何逻辑只是在确认mock对象按预期被调用了。我在代码评审里看到过一种测试核心业务方法里五六个依赖全被mock了然后断言这些mock对象都被调到了这本质上是在测试mock库本身没有任何价值。还有个原则不要mock你不拥有的代码。第三方库、框架提供的对象在集成层面应该用真实对象去测mock它们容易让你忽略版本升级带来的行为变化。自己写的模块之间用mock隔离是合理的。4. 工程化落地拆分、子测试与覆盖率的组合拳写了几个测试文件之后你会发现测试代码也需要架构设计。一个文件几百行一个类里十几个test_方法跑到中间某个失败后面的用例仍然会继续跑。排列组合的场景一多测试方法数量会爆炸式增长。4.1 子测试subTest当一个功能有多种输入组合每一种组合都值得验证时不要写一堆几乎相同的测试方法直接用subTestclass 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_strinput_str, sepsep): 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(verbosity2) 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位places7。如果业务场景要求更高的精度可以指定更大的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.ymlname: Run Unit Tests on: push: branches: [ main ] pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 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-artifactv4 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这件事越往后越会觉得它其实是在培养一种代码思维——写代码的时候自然会考虑它好不好测依赖关系是不是清晰状态管理是不是可控。这些反过来会让业务代码更干净。我现在接到一个模块的活会先跟产品对清楚业务规则然后列出需要覆盖的边界条件最后才开始写实现。等代码写完了测试也已经跟着写完了并不是额外多出来的一件事。这套工作流花了些时间适应但带来的稳定感是以前写完就跑、跑完就上线的方式完全没法比的。
返回列表