
我一直觉得判断一个Python开发者是不是真的上了台阶不用看他会多少框架也不用问他刷了多少算法题只问一句就够了你最近改了一个项目里的核心函数改完之后你慌不慌我见过太多人一听到要改某个模块第一反应是把所有调用它的地方手动点一遍浏览器开十几个页面生怕哪条链路悄无声息地断了。这种恐惧本质上是没有测试体系兜底。单元测试和集成测试就是为了让你在改代码的时候从“赌运气”变成“看报告”。这一章我准备按我实际项目里的套路来讲不整理论就讲怎么落地。读完这一章你能做到三件事第一给现有项目搭起一套pytest测试环境第二写出真正有用的单元测试而不是自欺欺人的“假测试”第三用集成测试把多个模块串起来验证并接进CI让每次提交都有机器自动把关。这套活儿适合所有已经能写点Python但还没好好搭过测试的人哪怕你以前只写过脚本照着做也没问题。1. 先把测试环境立起来项目布局与pytest安装1.1 pytest为什么值得从unittest换过去初学者第一次接触Python测试基本都是被unittest领进门的因为它是标准库不用装任何东西。但我实际用下来unittest最大的问题不是不能用而是太啰嗦。写一个测试要先继承TestCase断言还得查到底该用assertEqual还是assertTrue跑失败之后想看清楚实际值和期望值的差异还要费半天劲。pytest则完全是另一种体验。测试就是一个普通的test_开头的函数断言直接写assert就行失败的时候它会把两边的实际值打印得明明白白。我见过很多从unittest迁到pytest的人第一反应都是“原来写测试可以这么省事”。下面这个表是我在不同项目里的实际感受维度unittestpytest依赖标准库第三方需pip安装写法类继承样板代码多裸函数test_开头即可断言需要专用方法assert原生语法失败信息清晰参数化靠subTest或手工拼接内置parametrize装饰器准备清理setUp/tearDownfixture机制作用域灵活插件生态较弱pytest-cov、pytest-xdist等非常丰富还有个细节很多人不知道pytest完全兼容unittest.TestCase。如果你在维护老项目里面已经是unittest的写法了不需要推倒重来装上pytest直接跑老用例也能正常被发现和执行。平滑过渡没有迁移痛苦。1.2 一个干净的项目测试目录与最小配置我见过很多Python项目测试文件跟源码放在同一个目录里运行的时候全靠运气。正确做法是给项目做一个清晰的目录结构这一步花不了几分钟但能避免后面无数个“这个import怎么又错了”的坑。我这里给出一套我在多个项目中验证过的最小布局myproject/ ├── src/ │ └── myproject/ │ ├── __init__.py │ ├── calculator.py │ └── order.py ├── tests/ │ ├── __init__.py │ ├── conftest.py │ ├── test_calculator.py │ └── test_order.py ├── requirements-dev.txt ├── pytest.ini └── .gitignore为什么用src布局而不是把包直接放根目录这背后有个实在的原因如果包在根目录pytest运行时当前目录会被加进系统路径测试会直接import到源码目录里的包。万一你通过pip还装了一个同名的其他版本两个版本一混淆测试可能在你不自知的情况下测了错误的代码。src布局强制你先安装项目包保证测试对象是“真正会被运行的那个版本”。这一步很关键。接着是环境搭建。我在干净环境里的完整命令是mkdir myproject cd myproject python -m venv .venv source .venv/bin/activate # Windows下是 .venv\Scripts\activate pip install pytest pytest-cov依赖我习惯单独放一个文件管理# requirements-dev.txt pytest8.0 pytest-cov5.0然后是pytest的配置文件。别小看这个东西它决定了pytest怎么发现测试、用什么样的输出格式# pytest.ini [pytest] testpaths tests python_files test_*.py addopts -q --tbshorttestpaths告诉pytest只去tests目录找测试python_files只匹配test_开头的文件addopts里的-q是安静模式、--tbshort是出错时把traceback压缩到关键行。如果你用的是新版项目结构也可以把这套配置写进pyproject.toml的[tool.pytest.ini_options]里效果一样。1.3 第一个测试文件从运行到看到绿环境装好之后写第一个测试是最有成就感的。我拿一个最简单的计算器模块来演示。先写被测试的源码# src/myproject/calculator.py def add(a, b): return a b def divide(a, b): if b 0: raise ValueError(除数不能为0) return a / b然后写对应测试# tests/test_calculator.py import pytest from myproject.calculator import add, divide def test_add(): assert add(2, 3) 5 def test_divide_by_zero(): with pytest.raises(ValueError, match除数不能为0): divide(1, 0)运行方式很简单在项目根目录执行pytest pytest tests/test_calculator.py -v第一条命令会运行全部测试第二条只看某个文件并输出详细结果。看到2 passed in 0.02s这种绿色输出的时候你的项目就算正式有了测试体系。很多同学到这一步就觉得完事了但真正的难点还在后面怎么写断言才不是“为了写而写”、怎么处理测试之间的依赖、怎么让数据库和外部接口的测试稳定可靠。下面几章才是重点。2. 单元测试的骨架最常用的pytest断言与fixture2.1 断言不是蒙答案该测什么、怎么测很多人写测试最大的问题是断言写得“太安全”——assert result is not None、assert response.status_code 200这种断言几乎什么实现都能让它通过。一个不会失败的测试和没有测试没什么区别。我自己的判断标准是断言要能说明“这个函数在这个输入下应该有哪个具体的输出”而不是“它没报错”。具体到细节有几个实战里高频用到的断言姿势异常断言不要只写pytest.raises(ValueError)加上match参数去匹配异常消息能多一层保证。比如上面例子里match除数不能为0如果哪天有人把异常消息改了测试会立刻告诉你。浮点数比较assert 0.1 0.2 0.3是永远过不了的因为二进制浮点数的精度问题。用pytest.approxdef test_float(): assert 0.1 0.2 pytest.approx(0.3, abs1e-9)列表比较如果函数的输出和顺序无关直接assert result [a, b]会过于严格。这时候用sorted()或者转成集合再比但要小心集合会丢重复元素具体场景具体取舍。字符串子串assert django in str(response.content)这种写法比assert response.content ...更抗变化适合验证返回值里的某个关键片段。2.2 fixture测试前的准备与测试后的清理fixture这个词初看有点抽象但它是pytest的灵魂。我习惯把它理解成“测试的舞台搭建和拆台”一个测试需要数据库连接、需要临时文件、需要预置几条数据fixture负责把这些准备好测完之后该关的关、该删的删。好处是多个测试函数可以复用同一套准备代码不用每个函数里复制粘贴。fixture的核心价值在于“复用 自动清理”。举个例子一个需要操作临时文件的测试pytest.fixture def temp_data_file(tmp_path): p tmp_path / data.txt p.write_text(name: alice\nage: 30, encodingutf-8) yield p # yield 后面的代码会在测试结束后执行在这里做清理tmp_path是pytest内置的fixture专门给测试提供临时目录一个测试函数一个独立目录测试结束自动清掉。yield的写法是关键yield之前是准备阶段yield之后是清理阶段。如果你不需要清理也可以直接return但遇到“用完必须关连接”的场景yield几乎是唯一正解。fixture还有个重要的属性叫作用域。默认是function也就是每个测试函数都会重新走一遍准备和清理但如果你希望某个fixture在整个测试文件里只初始化一次可以加scopemodule如果要整个测试会话共享用scopesession。这里要特别提醒一句作用域越大会越快但也越容易让测试之间互相污染。我踩过的坑后面专门有一章讲这里先记住一个原则——默认用function确定没有共享状态的风险了再考虑放大作用域。2.3 参数化同样的逻辑多组数据你肯定遇到过这种情况一个函数有几十种典型输入为了测全只能复制十几个长得几乎一样的测试函数。pytest的parametrize就是为这个场景设计的pytest.mark.parametrize(a,b,expected, [ (2, 3, 5), (-1, 1, 0), (100, 200, 300), (0, 0, 0), ]) def test_add_params(a, b, expected): assert add(a, b) expected这样一条测试函数就覆盖了四组数据。跑测试的时候pytest会在输出里把每一组参数都列出来哪一组挂了一目了然。更进阶的用法是叠加两个parametrize生成笛卡尔积式的组合测试。比如第一个参数表示用户角色第二个参数表示操作类型跑一遍就等于做了一次小型矩阵测试很适合权限校验、状态机迁移这类逻辑。3. 隔离外部依赖mock到底该mock什么3.1 mock的边界该替的才替不该替的别替单元测试的核心理念是隔离。被测试函数如果调用了第三方API、连了数据库、发了消息队列那它在测试环境里天然就跑不起来——可能没网可能没权限可能依赖的服务根本没部署。这时候就需要mock翻译成大白话就是给外部依赖找一个“替身演员”。但mock是把双刃剑。我见过一个同事为了图省事把被测函数内部所有依赖全部mock了测试跑起来全绿结果上线照样出bug。为什么因为他测的是自己造出来的假环境不是真实的代码行为。mock的本质是隔离它应该隔离“不是这个函数职责范围”的部分而不是把函数自己的逻辑也替换掉。我自己的边界原则是这样的该mock的外部API调用、数据库连接、消息队列、当前时间、随机数这些不可控或不确定的边界。不该mock的自己项目里的业务函数、同一个模块内部的纯函数协作。这部分代码的测试价值恰恰在于让它们真实协作mock掉了等于白测。3.2 monkeypatch与Mock的选用时机pytest里有两套常用隔离工具monkeypatch是pytest内置的fixtureunittest.mock是Python标准库。我在实战里的选型逻辑很简单只是想临时替换某个对象的方法或环境变量用monkeypatch它自带自动还原不怕污染后面的测试。需要验证“这个函数被调用过、调用参数是什么、返回什么”用unittest.mock的Mock和patch。两者的区别我用一个实际例子说明。假设业务代码是这样的# src/myproject/order.py import requests def fetch_user_balance(user_id): resp requests.get(fhttps://api.example.com/user/{user_id}) if resp.status_code ! 200: raise ConnectionError(用户服务不可用) return resp.json()[balance]用monkeypatch替换请求函数代码是这样的def test_fetch_user_balance_success(monkeypatch): class FakeResp: status_code 200 def json(self): return {balance: 100} monkeypatch.setattr(myproject.order.requests.get, lambda url: FakeResp()) assert fetch_user_balance(1) 100用unittest.mock的patch还能进一步验证调用参数from unittest.mock import patch def test_fetch_user_balance_success_with_patch(): with patch(myproject.order.requests.get) as mock_get: mock_get.return_value.status_code 200 mock_get.return_value.json.return_value {balance: 100} assert fetch_user_balance(1) 100 mock_get.assert_called_once_with(https://api.example.com/user/1)注意一个高频坑patch里面的字符串写的是myproject.order.requests.get不是requests.get。原因很简单mock替换的是order模块里import进来的那个requests引用如果你patch了全局的requests模块order模块里已经绑定的引用是不会跟着变的。这个错位是刚上手时最容易蒙圈的点。3.3 一个带外部API调用的完整测试案例把上面的东西串成一个完整场景你写了一个函数根据用户ID从第三方服务拿余额然后换算成折扣。这里有两个外部依赖一个HTTP接口一个时间。测试方法可以这样组织# src/myproject/discount.py import time def get_discount_by_balance(balance): if balance 500: return 0.8 if balance 100: return 0.9 return 1.0 def fetch_discount(user_id): resp requests.get(fhttps://api.example.com/balance/{user_id}) balance resp.json()[balance] return get_discount_by_balance(balance)测试重点是验证fetch_discount把HTTP返回的balance正确传给了get_discount_by_balance而get_discount_by_balance本身的边界逻辑要单独用参数化测透。这样分工哪个环节出问题一眼就能定位。如果直接对fetch_discount做网络请求去测大概率有一天会跪在网速或者服务方接口改动上还很难排查。4. 集成测试的关键数据库与API的真实交互4.1 数据库测试事务回滚让每个测试从同一张白纸开始单元测试追求隔离集成测试恰恰相反追求真实。它验证的是“多个模块拼起来还能不能正常工作”数据库真的能写入吗HTTP服务真的能响应吗消息队列真的能收发吗这一层是很多入门者跳过的地方也可能是项目上线后翻车最多的地方。先说数据库这块最脏最累但最有效的一套做法是真实数据库 事务回滚。原理不复杂就是用事务包住每个测试测试结束直接回滚数据库不留任何痕迹。我用SQLAlchemy写过一个典型的fixturepytest.fixture def db_session(): from myproject.db import engine from sqlalchemy.orm import Session conn engine.connect() trans conn.begin() session Session(bindconn) yield session session.close() trans.rollback() conn.close()每个测试函数进来的时候拿到的是一个全新事务里的会话测完回滚数据回到原样。跑一万次测试数据库还是最初的干净状态。这比每个测试前手动删表、重建要可靠得多也快得多。这里有一个要特别提醒的坑如果被测代码里执行了session.commit()事务回滚依然能把最外层事务回滚掉但如果代码用了autocommit或者代码内部又开了一个新的数据库连接去操作回滚就会失效数据会真真实实写进库里。遇到这种情况要么专门准备一个测试库并在每次测试后清空要么把连接管理收敛到同一个事务里。两种方案按项目代价选没有绝对的好坏。4.2 API集成测试验证真实交互而不是验证模拟如果你的项目是Web服务API集成测试是性价比最高的一类测试。它的要点是启动真实的应用走真实的路由分发、依赖注入和序列化然后断言HTTP响应。我用FastAPI的TestClient举例它不需要真的监听端口但整个请求链路是真实走通的from fastapi.testclient import TestClient from main import app client TestClient(app) def test_create_user(): resp client.post(/users, json{name: Alice}) assert resp.status_code 200 data resp.json() assert data[id] is not None assert data[name] Alice这个测试会真实经过路由、参数校验、依赖注入、业务处理和响应序列化比直接用requests去请求线上环境稳定也比手动开服务更省事。非Web项目也有对应的集成测试写法。比如一个订单与库存协作的系统测试早就不是“调一个函数”而是先往订单表插入一条已知数据再调用库存扣减函数最后断言库存表的数字变化符合预期。核心模式是“输入已知数据驱动多个模块协作断言最终状态”。这类测试的价值在于它能在你还不知道哪个模块出错的时候先告诉你“整条链路有问题”帮你把排查范围快速缩小到某一层。4.3 测试隔离与执行顺序为什么有时候单独跑绿、一起跑红看到这个标题你应该有共鸣。pytest默认按文件名顺序执行测试如果A文件里的测试修改了某个共享变量B文件里的测试就会读到脏数据。我见过一种非常经典的现象单独跑test_user.py全绿单独跑test_order.py全绿两个文件一起跑test_order.py挂了。这不是随机失败更不是Flaky而是共享状态污染。要根治这个问题唯一的办法是让每个测试都假设“我是从零开始的”。该建的表建好、该回滚的事务回滚、该清空的缓存清空。千万别用全局变量去存“当前用户”“当前订单”之类的上下文这在测试世界里是最容易埋雷的做法。等踩过几次“单独跑绿、一起跑红”的坑你就会对测试隔离这四个字有刻骨铭心的理解。5. 测试质量怎么量覆盖率统计与持续集成接入5.1 覆盖率数字怎么读才不会被骗覆盖率coverage这个数字被太多人当成KPI来追。实际上它更像体检报告里的一个指标可以说明问题但不能代替医生的判断。我的建议是用覆盖率发现测试盲区不用它来证明“这个项目有质量”。先把工具用起来。装了pytest-cov之后一行命令就能看到覆盖率报告pytest --covsrc --cov-reporthtml --cov-branch--covsrc指定统计src目录--cov-reporthtml生成可以浏览器打开的HTML报告--cov-branch会连if/else的分支一起统计而不只是看每行代码有没有被执行过。打开生成的htmlcov/index.html你会看到每个文件的覆盖比例点进去还能看到具体哪一行没被跑到。这里我要泼一盆冷水行覆盖率100%只代表这些行被执行过不代表逻辑正确。比如一个除法函数你只测了正常除法没测除以0的异常分支行覆盖率可能是满的但异常处理逻辑其实是坏的。所以覆盖率这数字关键不是“越高越好”而是“能看出趋势”。我自己的做法是给核心模块设阈值比如80%低于这个值CI直接失败防止有人删测试或者新增代码不补测试。5.2 让每次提交自动跑测试一条命令打通CI测试写好了如果只在本地跑它就只是一堆躺在磁盘上的文件。真正的价值在于把它接进持续集成系统。以GitHub Actions为例项目里加一个.github/workflows/test.ymlname: test on: push: branches: [main] pull_request: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version: 3.11 - run: pip install -r requirements-dev.txt - run: pytest --covsrc --cov-fail-under80这个配置一提交每次push或者pull request都会自动拉代码、装依赖、跑测试覆盖率低于80%就直接亮红。这样团队里任何一个人提交了有问题的代码机器会在合并之前拦住他而不是等上线后用户来报告。GitLab CI的思路也一样跑的还是那几条命令只是yaml语法略有差异。核心要点是CI环境是干净的机器所以pip install必须包含全部测试依赖还要记得安装项目本身pip install -e .否则测试import不到src目录里的代码。5.3 测试跑太慢怎么办并行化与分层项目大了以后测试时间会从几秒膨胀到几分钟开发者就不想跑了。这是一个很危险的信号说明测试已经变成负担。解决慢的问题我有两个实战手段。第一是并行化。装一个pytest-xdist然后pip install pytest-xdist pytest -n auto-n auto会根据机器CPU核数自动拆分测试进程我的项目跑下来速度能快三四倍。但要注意测试之间有共享状态时并行会立刻暴露问题这反而是件好事——它会逼你把隔离做干净。第二是分层执行。把慢的集成测试单独打标记pytest.mark.integration def test_order_create_and_deduct(): ...日常开发就跑单元测试快速反馈pytest -m not integrationCI里再分两个job分别跑单元和集成测试。这样开发者本地几秒钟内就能得到反馈集成测试交给机器在后台慢慢跑谁也不用等。6. 我踩过的坑与排查过程6.1 共享session对象引起的“串味”排查有段时间我在做一个电商订单系统代码逻辑本身不复杂但测试一直不稳定。最典型的场景单独跑test_order.py能过单独跑test_user.py能过两个文件一起跑test_user.py就挂。这种问题最折磨人因为它不是每次都挂看起来像玄学。我当时的排查链路是这样的先把两个文件一起跑记下报错信息发现user测试里查数据库查到了不该出现的数据。单独重新跑test_user.py数据又正常了。说明数据残留是另一个测试写进去的。回去翻conftest.py发现里面定义了一个session级别的fixture持有一个全局数据库连接而它没有在每个测试之后做清理。把作用域从session改成function并给数据库操作加上事务回滚全量测试立刻稳定。这个坑给我的教训是测试隔离永远是第一优先级。为了省一点点初始化时间把连接状态放全局后面查问题的时间足够赔回十倍。如果你也遇到“测试单独跑都绿、一起跑就红”先怀疑共享状态不要怀疑执行顺序或者随机数。6.2 时间相关测试的常见翻车现场另一个让我印象深刻的坑是时间相关逻辑。项目里有个判断订单是否超时的函数内部直接调用了datetime.now()。我第一次写测试时用了最笨的办法time.sleep(1)去跨越时间的边界。结果测试偶尔过、偶尔挂因为边界情况很难精确卡住而且跑一次要等一秒钟测试越积越多整个套件越来越慢。后来用monkeypatch把时间固定住问题才彻底解决def test_order_is_expired(monkeypatch): from myproject import order fake_now datetime(2025, 1, 1, 12, 0, 0) monkeypatch.setattr(order.datetime, now, lambda: fake_now) order_obj create_order(created_atdatetime(2025, 1, 1, 11, 0, 0)) assert order.is_expired(order_obj) is True更稳妥的工程做法是让业务函数接收一个now参数由调用方传当前时间测试时显式传入固定时间。这叫依赖注入比mock更简单也更可预测。从那以后我在设计新函数时都会先想想这个函数的输入里要不要把时间这种天然不确定的东西变成显式参数这个习惯帮我省掉了非常多测试痛点。6.3 mock“替身”之间互相干扰的排查还有一次是mock之间的互相干扰。两个测试文件里都用patch(myproject.order.requests.get)替换同一个函数A文件里的mock返回A数据B文件里的mock返回B数据。单独跑都能过一起跑B就挂了。排查过程给了我一个深刻的教训patch在进程级别是共享的如果作用域没控制好后执行的测试会覆盖前面测试的mock配置。解决方法是严格使用with patch(...)或装饰器形式确保每个mock在用例结束后立刻还原避免在模块顶层直接给全局变量赋Mock对象。另外我还发现一个隐藏的好处用了create_autospec之后mock出来的对象会保留真实函数的签名如果上游接口改了参数测试会在调用时立刻报错而不是等到上线后接口断在莫名其妙的地方。6.4 顺带说一个文件命名的小坑最后补一个不算大但特别影响节奏的细节pytest默认只匹配test_开头的文件python_files test_*.py这个配置也通常只匹配这个规则。如果你图省事把一个测试文件命名为utils_test.py它默认不会被自动发现跑全量测试的时候就像从没存在过一样。我见过不止一个同事因为这个问题以为自己的测试一直在跑实际上跑了几个月全是空集。统一用test_前缀别创新这毛病我犯过一次写在这里希望你能跳过。我现在写Python的习惯是一个新函数写完第一件事不是跑它而是先写测试。倒不是我多有纪律性而是吃过太多次“改一个函数坏三个地方”的亏。测试真正改变的是心理状态它把“我不敢动”变成“我改了、测试跑一遍、绿了就敢继续推进”。如果你刚开始写测试不用追求全套挑一个项目里最容易出问题的模块先把pytest跑起来把一个用例写绿后面自然就顺了。第26章到这里只是开了个头pytest的fixture插件、mock的高级用法还多得很但地基就是这一套环境隔离、断言可读、边界干净。把这套地基夯实后面学什么都快。