第27章:HTTP Mock测试——responses与requests-mock

第27章:HTTP Mock测试——responses与requests-mock
1. 项目背景业务场景某支付平台的订单服务需要调用银行扣款 API。团队为这个服务编写了单元测试——但测试跑一次要 15 秒真实网络请求到银行测试环境而且非工作时间银行测试环境会维护22:00-06:00 不可用。更糟糕的是某次测试中误用了生产环境的 API Key测试脚本写了死数据导致银行测试环境真的扣了款——测试数据变成了真实交易引发了一场紧急回滚。团队决定引入 HTTP Mock——让测试在零网络依赖、毫秒级完成的情况下运行。但两个主流 Mock 库的出现让团队产生了选择困难responses拦截requests模块级别的调用和requests-mock作为 Adapter 拦截 Session 级别的调用。痛点问题一测试依赖外部服务——慢、不稳定、不可控。依赖真实 API 的单元测试有三大问题1慢网络延迟 100ms/次2不稳定外部服务随时可能宕机3不可控无法模拟 5xx 错误、超时、限流等异常场景。如果测试失败是因为银行测试环境挂了而不是代码 bug开发和排查成本都很高。问题二Mock 库的选择困惑。responses和requests-mock都能拦截 HTTP 请求并返回预设响应但它们的实现机制不同。responses通过 monkey-patchurllib3的适配器来拦截所有请求包括通过requests.get()顶层函数发出的。requests-mock通过在 Session 上 mount 一个 Mock Adapter 来拦截请求——对顶层requests.get()不生效。如果选错了库Mock 可能不生效测试中偷偷发出了真实请求或者误拦截了不该拦截的请求。问题三Mock 不够真实导致测试失真。简单的Mock返回 {status: ok}可能让测试通过但真实 API 的响应可能包含很多复杂字段——嵌套 JSON、特定 HTTP 头、延迟等。如果 Mock 不够真实测试通过的代码在生产环境可能崩溃比如没处理某个意外为 null 的字段。responses vs requests-mock 对比responses: 拦截层面: urllib3 adapter 层全局 适用场景: 拦截所有 HTTP 请求包括顶层 requests.get() 注册方式: responses.activate 装饰器或上下文管理器 Mock 粒度: URL 精确匹配或正则匹配 requests-mock: 拦截层面: Session adapter 层 适用场景: 需要精确控制特定 Session 的 Mock 注册方式: with requests_mock.Mocker() as m: Mock 粒度: URL 精确匹配、正则匹配、自定义匹配函数2. 项目设计小胖在晚上 11 点加班对着测试报错发呆“大师我的单元测试又挂了——但不是代码问题是银行测试环境在维护而且我发现测试跑了 50 个用例每个都要真实调银行 API总共要跑 3 分钟——CI 流水线经常超时”大师“单元测试的第一原则——不依赖外部服务。你的测试应该在任何时间、任何网络环境下都能跑而且应该在毫秒级完成。你需要 HTTP Mock。”小胖眼睛一亮“Mock就是假装调了 API实际上返回一个预设的响应那怎么用”大师“有两个主流选择——responses和requests-mock。我先给你看最简单的responses”importresponsesimportrequestsresponses.activatedeftest_my_api():# 注册 Mock当请求这个 URL 时返回预设的响应responses.get(https://api.bank.com/charge,json{status:success,txn_id:TXN123},status200,)# 业务代码完全不知道自己在调 Mockresprequests.post(https://api.bank.com/charge,json{amount:100})assertresp.json()[status]success# 通过小白追问“那responses和requests-mock有什么区别我该选哪个”大师“关键区别responses是全局拦截——你激活它之后所有requests发出的请求不管是requests.get()还是session.get()都会被拦截。requests-mock是在 Session 级别挂载一个 Mock Adapter只有通过那个特定 Session 发出的请求才会被拦截。”“选型建议如果你的代码用的是requests.get()顶层函数没有 Session用responses。如果你有明确的 Session 对象用requests-mock更精准——不会误拦截其他测试的请求。”小胖“那如何 Mock 异常场景比如超时、500、连接失败——这些在真实环境很难触发。”大师“这正是 Mock 的最大价值——你可以 Mock 任何场景包括那些在真实环境几乎不会遇到但必须处理的异常”# Mock 超时responses.get(url,bodyrequests.exceptions.Timeout())# Mock 500 错误responses.get(url,status500,json{error:Internal Server Error})# Mock 网络错误responses.get(url,bodyrequests.exceptions.ConnectionError())生活比喻技术映射飞行员模拟仓不用真飞机训练HTTP Mock 零网络测试预设发动机故障场景训练Mock 5xx 异常场景模拟信号发生器预设 Mock 响应只测刹车系统不测引擎requests-mock 精准拦截特定 Session全车检测responses 全局拦截3. 项目实战环境准备pipinstallrequests responses requests-mock pytest分步实现步骤一responses——全局 Mock 拦截目标使用 responses 拦截所有 HTTP 请求返回预设响应。importresponsesimportrequests# 基础用法Mock GET 请求 responses.activatedeftest_basic_mock():基础 Mock——拦截请求并返回预设 JSONresponses.get(https://api.example.com/users,json{users:[{id:1,name:小胖}]},status200,)resprequests.get(https://api.example.com/users)assertresp.status_code200assertresp.json()[users][0][name]小胖print(基础 Mock 通过 ✓)# 高级用法Mock 不同 HTTP 方法和状态码 responses.activatedeftest_multiple_mocks():Mock 多种 HTTP 方法和异常状态码# Mock GET——成功responses.get(https://api.example.com/items/1,json{id:1,name:item1},status200,)# Mock POST——创建成功responses.post(https://api.example.com/items,json{id:2,name:new_item},status201,)# Mock GET——404responses.get(https://api.example.com/items/999,json{error:Not Found},status404,)# Mock GET——500responses.get(https://api.example.com/items/500,json{error:Internal Error},status500,)# 执行测试assertrequests.get(https://api.example.com/items/1).status_code200assertrequests.post(https://api.example.com/items).status_code201assertrequests.get(https://api.example.com/items/999).status_code404assertrequests.get(https://api.example.com/items/500).status_code500print(多方法/多状态码 Mock 通过 ✓)# Mock 异常场景 responses.activatedeftest_mock_exceptions():Mock 网络异常超时、连接失败# Mock 超时responses.get(https://api.example.com/slow,bodyrequests.exceptions.Timeout(),)# Mock 连接错误responses.get(https://api.example.com/offline,bodyrequests.exceptions.ConnectionError(),)# 验证超时try:requests.get(https://api.example.com/slow,timeout1)assertFalse,应该抛出超时异常exceptrequests.exceptions.Timeout:print(超时 Mock 通过 ✓)# 验证连接错误try:requests.get(https://api.example.com/offline)assertFalse,应该抛出连接异常exceptrequests.exceptions.ConnectionError:print(连接错误 Mock 通过 ✓)# 运行测试 test_basic_mock()test_multiple_mocks()test_mock_exceptions()步骤二requests-mock——Session 级别精准拦截目标使用 requests-mock 在 Session 级别进行精准 Mock。importrequests_mockimportrequestsdeftest_with_requests_mock():requests-mock 拦截特定 Session 的请求# 使用上下文管理器只拦截 inside 的请求withrequests_mock.Mocker()asm:# 注册 Mock 路由m.get(https://api.example.com/users,json{users:[{id:1}]},status_code200,)m.post(https://api.example.com/users,json{id:2,created:True},status_code201,)# 正则匹配m.get(requests_mock.ANY,json{fallback:True},)# 业务代码通过 Session 发请求sessionrequests.Session()respsession.get(https://api.example.com/users)assertresp.json()[users][0][id]1respsession.post(https://api.example.com/users)assertresp.json()[created]isTrue# 不匹配任何注册路由的请求返回通配 Mockrespsession.get(https://api.example.com/anything)assertresp.json()[fallback]isTrueprint(requests-mock 通过 ✓)deftest_requests_mock_matchers():requests-mock 的高级匹配功能withrequests_mock.Mocker()asm:# JSON 请求体验证m.post(https://api.example.com/orders,additional_matcherlambdareq:req.json().get(amount)0,json{order_id:ORD-001},status_code201,)sessionrequests.Session()# 应该匹配amount 0respsession.post(https://api.example.com/orders,json{amount:100,item:book},)assertresp.status_code201# 不应该匹配未注册对应的 Mocktry:respsession.post(https://api.example.com/orders,json{amount:-1,item:return},)# 可能返回通配 Mock 或 NoMockAddressexceptrequests_mock.exceptions.NoMockAddress:print(预期负金额请求未被 Mock ✓)test_with_requests_mock()test_requests_mock_matchers()步骤三pytest fixture——可复用的 Mock 配置目标封装 pytest fixture在多个测试间复用 Mock 配置。importpytestimportrequestsimportresponses# responses fixture pytest.fixturedefmock_api():统一的 API Mock fixturewithresponses.RequestsMock()asrsps:# 注册通用 Mockrsps.get(https://api.example.com/health,json{status:ok},status200,)rsps.get(https://api.example.com/users/1,json{id:1,name:小胖,email:xiaopangtest.com},status200,)rsps.post(https://api.example.com/users,json{id:2,name:new_user},status201,)yieldrsps# 使用 fixturedeftest_get_user(mock_api):使用 fixture 中的 Mockresprequests.get(https://api.example.com/users/1)assertresp.status_code200assertresp.json()[name]小胖deftest_create_user(mock_api):resprequests.post(https://api.example.com/users)assertresp.status_code201# requests-mock fixture pytest.fixturedefmock_session():带 Mock 的 Session fixtureimportrequests_mockwithrequests_mock.Mocker()asm:m.get(https://api.example.com/data,json{result:mocked})m.post(https://api.example.com/action,json{status:done},status_code200)yieldmdeftest_with_mock_session(mock_session):sessionrequests.Session()respsession.get(https://api.example.com/data)assertresp.json()[result]mocked可能遇到的坑及解决方法坑1responses 未激活时忘记 responses.activate# 如果不用装饰器Mock 注册了但不会生效# 请求会发到真实的 URL# 解决始终用 responses.activate 或 with responses.RequestsMock()坑2requests-mock 对顶层 requests.get() 不生效# requests_mock 只拦截通过注册了 Mock Adapter 的 Session 发出的请求# requests.get() 创建的是临时 Session不会被拦截# 解决用 responses 替代或确保代码使用 Session坑3Mock 注册顺序影响匹配# responses 按注册顺序匹配先匹配到的生效# 将更具体的 URL 注册在前面通用匹配在最后测试验证importpytestimportrequestsimportresponsesclassTestMockStrategy:验证 Mock 测试策略responses.activatedeftest_mock_prevents_real_request(self):验证 Mock 阻止了真实网络请求responses.get(https://nonexistent-domain-12345.com/api,json{ok:True},status200,)resprequests.get(https://nonexistent-domain-12345.com/api)assertresp.json()[ok]isTrue# Mock 生效没有真实请求responses.activatedeftest_unmatched_url_raises(self):验证未注册的 URL 会触发 ConnectionError# 没有为这个 URL 注册 Mockwithpytest.raises(requests.exceptions.ConnectionError):requests.get(https://api.not-mocked.com/data)4. 项目总结核心知识点Mock 库拦截层面激活方式适用场景responsesurllib3 adapter (全局)responses.activate顶层requests.get()requests-mockSession adapterwith Mocker()通过 Session 调用unittest.mock函数级别patchMock 特定函数返回值优点 缺点对比维度responsesrequests-mock全局拦截✅ 是❌ 仅拦截特定 Session对顶层 API 生效✅❌精细度中URL 匹配高自定义 matcher异常模拟✅ 直接抛异常对象✅ 直接抛异常对象适用场景单元测试所有外部 HTTP 依赖必须 MockCI/CD 流水线零网络依赖毫秒级完成异常场景测试模拟超时、5xx、网络错误本地开发离线环境下也能跑全量测试注意事项Mock 后务必验证 Mock 被调用responses.assert_call_count()测试中不要混用真实请求和 Mock 请求Mock 数据应贴近真实 API 响应格式避免测试失真CI 中确保 Mock 框架版本与 requests 版本兼容常见踩坑经验案例一Mock 未激活但测试通过——假阳性。某测试中responses.activate装饰器被误删但测试仍然通过——因为 CI 环境恰好能访问那个测试 API。三个月后 API 下线测试失败才发现 Mock 从未生效。根因未验证 Mock 是否被调用。修复每个测试末尾加responses.assert_call_count(url, 1)。案例二Mock 数据与真实 API 格式不一致。用{status: ok}做 Mock但真实 API 返回的是{code: 200, data: {...}}。代码在测试中正常运行上线后解析失败。根因Mock 数据与真实格式脱节。修复Mock 数据应从真实 API 文档或抓包获取。案例三requests-mock 对 dependencies 中的 requests.get() 不生效。被测代码依赖一个第三方库该库内部用requests.get()发请求。requests-mock 挂在 Session 上第三方库不走 SessionMock 未生效。根因选错了 Mock 库。修复改用 responses全局拦截或 Mock 第三方库调用。思考题设计题你的系统依赖 10 个第三方 API每个 API 返回格式不同。如何设计一个Mock 数据工厂——能从真实 API 响应的抓包数据HAR 文件/JSON自动生成 Mock 配置减少手动编写 Mock 数据的工作量集成题单元测试和集成测试的边界在哪哪些场景必须用真实请求集成测试/E2E 测试而不是 Mock如何设计 CI 流水线使得 Mock 测试和真实请求测试都能高效运行延伸阅读与资源Milvus向量数据库实战修炼从 0 到 1精通向量检索与生产落地后端工程师的 AI 转型第一课Ollama 与私有化大模型实战10倍开发者的 Dify 魔法书从零构建全栈 AI 应用后端工程师转型AI第一课-Ollama 与私有化大模型实战大型语言模型(LLM) vLLM 高性能推理落地实战Agent开发之LlamaIndex 实战修炼与源码进阶大语言模型Transformers 实战修炼与源码剖析