
1. 项目背景业务场景食光集市测试团队有 3 名专职 QA 和 1200 条手工测试用例。每次 Sprint 结束前的回归测试需要两个人花整整两天用 Excel 逐条勾选、手工截图、粘贴到测试报告中。上一期 Sprint一切都正常——单元测试全绿、手工回归测试全绿。然而上线当天支付回调的沙箱环境突然不能用了——因为第三方支付平台未经通知地把沙箱的return_url字段从可选改成了必填。食光集市的代码没有 mock 支付回调的异常路径手工测试用的也是沙箱沙箱那时刚好维护完所以测试通过了导致这个场景从未被验证过。上线 4 小时后运营发现所有通过沙箱支付的订单全部卡在待支付状态——实际上用户已经付了钱但回调没被正确处理。这笔资损最后被财务定性为测试覆盖不足导致的线上事故。更糟糕的是——手工回归测试覆盖不到的盲区远不止这一个第三方服务的异常返回超时、500、返回畸形 JSON分布式并发冲突场景两个请求同时操作同一订单数据库迁移后的向后兼容性旧版本客户端调新版本 API这些问题靠手工测试永远测不完。CTO 定下目标“从下个 Sprint 开始CI 流水线中的自动化测试必须覆盖——Lint → 单元测试 → 契约测试 → 集成测试。测试金字塔必须立起来。”痛点缺乏结构化测试体系团队会遭遇手工回归永远测不完1200 个用例每个 Sprint 新增 50 个——人力不可扩展。第三方依赖不稳定支付沙箱、地图 API、短信服务——它们随时可能变慢、不可用、改变返回格式。没有 mock 就无法测试异常路径。单测绿、联调红A 团队改了 API 返回字段但没通知 B 团队——因为缺少一份自动执行的接口契约测试。CI 流水线无质量门禁代码 push 后不跑任何自动化检查——直到手工测试阶段才暴露问题。测试金字塔 ╱ E2E ╲ (少量——关键业务流程) ╱ 集成测试 ╲ (中等——跨服务交互) ╱ 契约测试 ╲ (中等——API 边界) ╱ 单元测试 ╲ (大量——函数级正确性) ╱ Lint/格式化 ╲ (基础——代码风格)2. 项目设计场景CI 流水线搭建讨论会上小胖看到测试金字塔的第一反应是这成本太高了。小胖“大师单元测试我认了——快速、稳定、好写。但契约测试和集成测试那不是又慢又不稳定吗我上次写的集成测试跑一次 12 分钟还经常因为网络问题随机失败。这不是在浪费 CI 分钟数吗”小白“小胖你的直觉部分正确——越往上层的测试确实越慢、越不稳定。但你不能因为它们’不完美’就放弃。关键是——测试金字塔不是为了追求完美而是为了在成本和质量之间找到最优平衡。我有一个更具体的问题——mock 和 stub 和 fake 有什么区别我见过三种术语混用。”大师“小白问到了测试替身Test Double的精确分类。这是写好测试的理论基础”替身类型作用示例是否验证交互Stub返回预设值mock_get.return_value {status: ok}否Mock验证是否被调用mock_send.assert_called_once_with(...)是Fake轻量级真实实现fakeredis、内存 SQLite否Spy记录调用信息记录哪些参数被传递可选“简单记忆法——Stub 管’给我什么’Mock 管’我有没有被正确调用’。Mock 验证的是行为behavior verificationStub 提供的是状态state verification。”“关于集成测试慢和不稳定的问题——用 Fake 替代真实依赖。不是 mock 掉 Redis而是用一个进程内的fakeredis纯 Python 实现的 Redis 兼容库。行为与真实 Redis 基本相同但启动零开销。同理——用 SQLite 的:memory:替代 Postgres 做集成测试速度快 100 倍。”技术映射Stub 自动售货机的测试面板——你按可乐它就出可乐返回值预设但不验证你按了几次。Mock 监控探头——不仅出可乐还记录有人按了 3 次可乐按钮1 次雪碧按钮。Fake 驾校模拟器——坐在上面跟真车感觉一样行为兼容但不是真车。小胖“契约测试呢我听说过 Pact——它好像是消费者驱动的契约测试。但它和单元测试、集成测试的关系是什么放在金字塔的哪一层”小白“契约测试本质是接口边界的握手验证——提供方保证’我会按这个格式返回’消费方验证’我会按这个格式解析’。这解决了我们之前’改字段不通知下游’的问题。但我关心的是——契约测试和 OpenAPI Schema 校验有什么关系有了 OpenAPI 还需要契约测试吗”大师“契约测试和 OpenAPI Schema 校验是互补关系不是替代”OpenAPI Schema 校验验证返回的 JSON 结构是否符合 API 文档——比如order_id必须是字符串。这是语法层的校验。契约测试验证返回的 JSON 内容是否满足消费方的预期——比如order_id必须以SG-开头。这是语义层的校验。“举例——OpenAPI 校验能发现amount字段缺失但不能发现amount从Decimal(28.00)变成了Decimal(27.99)——后者需要消费方的业务规则校验而这正是契约测试的范畴。”金字塔层级契约测试在单元测试之上、E2E 测试之下。它比单元测试慢需要起一个 HTTP 服务但比 E2E 快不需要真实数据库和下游服务。3. 项目实战CI 测试流水线环境准备依赖版本说明Python3.13.14基准版本fastapi0.115HTTP 服务pytest8.3测试框架ruff0.8Lint 格式化httpx0.28契约测试客户端mkdirfoodmarket-ch25cdfoodmarket-ch25 python-mvenv .venv .venv\Scripts\activate pipinstallfastapi uvicorn pytest httpx ruff分步实现步骤1被测试的支付回调服务src/payment_service.py支付回调处理——被测试的目标代码importloggingfromdataclassesimportdataclassfromdecimalimportDecimal loggerlogging.getLogger(__name__)classPaymentGatewayError(Exception):passclassPaymentDeclinedError(PaymentGatewayError):passclassPaymentTimeoutError(PaymentGatewayError):passdataclassclassPaymentResult:order_id:strsuccess:booltransaction_id:stramount:Decimal gateway_message:strclassPaymentGateway:第三方支付网关——真实实现需网络defprocess(self,order_id:str,amount:Decimal)-PaymentResult:# 实际代码会调 HTTP API——这里只定义接口raiseNotImplementedError(需注入具体实现)classOrderService:订单服务——依赖支付网关def__init__(self,gateway:PaymentGateway):self.gatewaygatewaydefprocess_payment(self,order_id:str,amount:Decimal)-dict:try:resultself.gateway.process(order_id,amount)exceptPaymentTimeoutError:return{status:retry,message:支付超时请重试}exceptPaymentDeclinedErrorase:return{status:declined,message:str(e)}exceptPaymentGatewayErrorase:return{status:error,message:str(e)}ifresult.success:logger.info(f支付成功:{order_id}tx{result.transaction_id})return{status:paid,transaction_id:result.transaction_id}else:return{status:failed,message:result.gateway_message}步骤2单元测试——Mock 支付网关tests/test_unit.py单元测试——Mock 支付网关验证 OrderService 行为importpytestfromdecimalimportDecimalfromunittest.mockimportMock,sentinelfromsrc.payment_serviceimport(OrderService,PaymentGateway,PaymentTimeoutError,PaymentDeclinedError,PaymentGatewayError,PaymentResult,)classTestOrderService:纯单元测试——不依赖任何外部服务deftest_successful_payment(self):# ArrangeStub 返回预设结果gatewayMock(specPaymentGateway)gateway.process.return_valuePaymentResult(order_idSG-001,successTrue,transaction_idTXN-ABC,amountDecimal(50.00),gateway_messageOK,)svcOrderService(gateway)# Actresultsvc.process_payment(SG-001,Decimal(50.00))# Assertassertresult[status]paidassertresult[transaction_id]TXN-ABCgateway.process.assert_called_once_with(SG-001,Decimal(50.00))deftest_payment_timeout(self):# ArrangeMock 抛出超时异常gatewayMock(specPaymentGateway)gateway.process.side_effectPaymentTimeoutError(连接超时)svcOrderService(gateway)resultsvc.process_payment(SG-001,Decimal(50.00))assertresult[status]retryassert超时inresult[message]deftest_payment_declined(self):gatewayMock(specPaymentGateway)gateway.process.side_effectPaymentDeclinedError(卡余额不足)svcOrderService(gateway)resultsvc.process_payment(SG-002,Decimal(100.00))assertresult[status]declineddeftest_generic_gateway_error(self):gatewayMock(specPaymentGateway)gateway.process.side_effectPaymentGatewayError(内部错误)svcOrderService(gateway)resultsvc.process_payment(SG-003,Decimal(30.00))assertresult[status]error步骤3契约测试——验证 API 响应格式tests/test_contract.py契约测试——验证 API 接口的请求/响应契约importpytestfromhttpximportAsyncClient,ASGITransport# 被测试的 FastAPI 应用fromfastapiimportFastAPIfrompydanticimportBaseModel,Field appFastAPI()classCallbackRequest(BaseModel):order_id:strField(...,patternr^SG-)transaction_id:stramount:str# Decimal → strstatus:str# success | failedclassCallbackResponse(BaseModel):code:str# OK | ERRORmessage:strapp.post(/payment/callback)asyncdefpayment_callback(body:CallbackRequest):ifbody.statussuccess:returnCallbackResponse(codeOK,message回调处理成功)returnCallbackResponse(codeERROR,messagef支付失败:{body.status})# ── 契约测试 ──pytest.fixtureasyncdefclient():asyncwithAsyncClient(transportASGITransport(appapp),base_urlhttp://test)asac:yieldacclassTestPaymentCallbackContract:契约测试——消费者视角验证 API 行为pytest.mark.asyncioasyncdeftest_success_callback_returns_code_ok(self,client):respawaitclient.post(/payment/callback,json{order_id:SG-20250315-001,transaction_id:TXN-001,amount:50.00,status:success,})assertresp.status_code200dataresp.json()assertdata[code]OK# 契约成功时 code 必须是 OKassertmessageindata# 契约必须有 message 字段pytest.mark.asyncioasyncdeftest_failed_callback_returns_code_error(self,client):respawaitclient.post(/payment/callback,json{order_id:SG-20250315-002,transaction_id:TXN-002,amount:30.00,status:failed,})assertresp.status_code200dataresp.json()assertdata[code]ERRORpytest.mark.asyncioasyncdeftest_invalid_order_id_rejected(self,client):契约order_id 必须以 SG- 开头respawaitclient.post(/payment/callback,json{order_id:INVALID-001,transaction_id:TXN-003,amount:10.00,status:success,})assertresp.status_code422# Pydantic 校验失败pytest.mark.asyncioasyncdeftest_missing_field_rejected(self,client):契约缺少必填字段返回 422respawaitclient.post(/payment/callback,json{order_id:SG-001,})assertresp.status_code422步骤4CI 流水线配置.github/workflows/ci.ymlGitHub Actions 示例name:CI Pipelineon:[push,pull_request]jobs:lint:runs-on:ubuntu-lateststeps:-uses:actions/checkoutv4-uses:actions/setup-pythonv5with:python-version:3.13-run:pip install ruff-run:ruff check src/ tests/-run:ruff format--check src/ tests/unit-test:needs:lintruns-on:ubuntu-lateststeps:-uses:actions/checkoutv4-uses:actions/setup-pythonv5with:python-version:3.13-run:pip install-r requirements.txt-run:python-m pytest tests/test_unit.py-vcontract-test:needs:lintruns-on:ubuntu-lateststeps:-uses:actions/checkoutv4-uses:actions/setup-pythonv5with:python-version:3.13-run:pip install-r requirements.txt-run:python-m pytest tests/test_contract.py-v运行测试ruff check src/ tests/# Lintruffformat--checksrc/ tests/# 格式检查python-mpytest tests/test_unit.py-v# 单元测试python-mpytest tests/test_contract.py-v# 契约测试完整代码清单foodmarket-ch25/ ├── .github/workflows/ │ └── ci.yml ├── src/ │ └── payment_service.py ├── tests/ │ ├── test_unit.py │ └── test_contract.py └── requirements.txt4. 项目总结优点 缺点测试层级速度稳定性覆盖范围维护成本发现 bug 类型Lint★★★★★ 毫秒★★★★★语法/风格★★★★★ 零语法错误单元测试★★★★★★★★★★函数级★★★★逻辑错误契约测试★★★★★★★★接口级★★★接口不兼容集成测试★★★★★★服务级★★★配置/中间件E2E 测试★★★★全链路★★业务流程断裂适用场景CI 质量门禁Lint → 单元测试 → 契约测试 三关全部通过才允许合并到主分支。第三方依赖的异常路径验证用 Mock 模拟支付超时、地图服务宕机、短信网关返回畸形数据。微服务间 API 兼容性提供方 CI 跑契约测试验证不破坏已有消费者的格式约定。重构安全网每次大重构后跑全量单元测试5 分钟内得到改没改坏的反馈。新人 on boarding新人改代码后CI 自动跑测试——不通过就拦下来降低人工 code review 的负担。不适用场景探索性原型需求还没确定的 PoC 阶段测试可能明天就扔。先写功能再补测试。UI 视觉验证按钮颜色、动画效果——单元测试和契约测试都覆盖不了需要视觉回归测试工具。注意事项unittest.mock.patch的 target 必须是 import 路径patch(src.payment_service.PaymentGateway)而不是patch(payment_service.PaymentGateway)——原因是 mock 替换的是被测试模块中 import 的那个名字。契约测试不替代集成测试契约测试保证格式对集成测试保证业务对——二者不重合。Mock(specPaymentGateway)的意义加了specMock 对象只允许访问 PaymentGateway 类上存在的方法/属性——防止你 mock 了一个不存在的方法还以为在测真实接口。CI 中并行跑不同层级的测试Lint、单元测试、契约测试可并行互不依赖缩短整体 CI 时间。常见踩坑经验故障案例1Mock 了一个不存在的方法——测试永远通过现象gateway Mock(); gateway.porcess.return_value ...process拼成porcess测试通过但上线后AttributeError。根因Mock()默认允许任意属性访问——你调用mock.whatever()它都返回一个新的 Mock不报错。修复Mock(specPaymentGateway)——访问不存在的方法时抛出AttributeError。故障案例2assert_called_once_with没考虑对象相等现象mock_fn.assert_called_once_with(user)突然失败因为User类没实现__eq__。根因assert_called_once_with用比较参数——如果参数对象没有__eq__比较的是内存地址两个内容相同的对象被视为不等。修复对复杂的参数用mock_fn.assert_called_once()先验证调用次数再用call_args手写断言。故障案例3CI 中 ruff 版本不一致导致本地和 CI 结果不同现象本地ruff check通过CI 中报 3 个新规则错误。根因本地 ruff 版本是上个月装的CI 每次都pip install ruff最新版新版本引入了新规则。修复在requirements-dev.txt中固定ruff0.8.4CI 用同版本。思考题unittest.mock.MagicMock和unittest.mock.Mock的区别是什么在什么场景下更适合用MagicMock一个微服务的消费者契约测试断言了response[order][amount]必须是字符串29.99。如果提供方把金额从Decimal改为int分序列化后变成2999——契约测试能发现这个 breaking change 吗如果不能还应该加什么层面的验证答案见中级篇综合实战章附录。延伸阅读与资源Python 3实战精进从脚本到高并发订单引擎MongoDB 实战进阶与内核修炼python入门Rquests从菜鸟脚本到企业级SDK的网络实战圣经Milvus向量数据库实战修炼从 0 到 1精通向量检索与生产落地后端工程师的 AI 转型第一课Ollama 与私有化大模型实战10倍开发者的 Dify 魔法书从零构建全栈 AI 应用后端工程师转型AI第一课-Ollama 与私有化大模型实战大型语言模型(LLM) vLLM 高性能推理落地实战Agent开发之LlamaIndex 实战修炼与源码进阶大语言模型Transformers 实战修炼与源码剖析