Skip to content

Latest commit

 

History

History
172 lines (121 loc) · 6.08 KB

03-mocking-testing.md

File metadata and controls

172 lines (121 loc) · 6.08 KB

Mock 测试 — 隔离外部依赖

文档简介: 用 monkeypatch、pytest-mock 与 AsyncMock 隔离外部服务,让测试只验证自己的逻辑——快、稳、无副作用

目标读者: 被慢测试或不稳定测试困扰的开发者

前置知识: 单元测试

文档信息(用途、难度与维护记录)

📚 文档元数据

属性 内容
模块 10-python-discovery
象限 操作指南
难度 ⭐⭐
标签 #monkeypatch #mock #pytest-mock #respx
更新日期 2026年9月

🎯 学习目标

完成本文档后,你将能够:

  • ✅ 判断什么该 mock、什么不该 mock
  • ✅ 用 monkeypatch 替换环境变量与模块属性
  • ✅ 用 pytest-mock 的 mocker 验证交互
  • ✅ 用 AsyncMock 处理异步依赖,用 respx 拦截 httpx

1. 什么该 Mock

依赖 建议
纯函数 / 数据结构 不 mock,直接测
数据库(自有) 测试库真连(见集成测试)
第三方 HTTP API mock(respx)
时间、随机、环境变量 mock(monkeypatch)
发邮件 / 短信等副作用 mock

原则:mock 你不拥有的边界;mock 自己写的实现等于没测。

2. monkeypatch:pytest 内置

# 被测代码 notify.py
import os
import httpx

def send_alert(message: str) -> bool:
    webhook = os.environ["ALERT_WEBHOOK"]              # 外部配置
    resp = httpx.post(webhook, json={"text": message})
    return resp.is_success
# tests/test_notify.py
import httpx
from notify import send_alert

def test_send_alert_uses_env(monkeypatch):
    monkeypatch.setenv("ALERT_WEBHOOK", "http://hook.test/x")

    def fake_post(url, **kwargs):                      # 替换模块属性
        assert url == "http://hook.test/x"
        return httpx.Response(200)

    monkeypatch.setattr("notify.httpx.post", fake_post)
    assert send_alert("磁盘告警") is True
# 测试结束自动还原,无需手写 finally

要点:monkeypatch.setattr 的目标写成 "模块.路径.属性" 字符串最不易错;fixture 参数注入,测试结束自动撤销一切替换。

3. pytest-mock:mocker 更顺手

uv add --dev pytest-mock
# 被测代码 user_service.py 中:注册成功后调用 send_welcome_email(email)

def test_register_sends_welcome(mocker):
    send = mocker.patch("user_service.send_welcome_email")   # 自动还原
    user_service.register("a@b.c", "secret123")
    send.assert_called_once_with("a@b.c")                    # 验证交互

要点:

  • patch 路径必须是被测模块引用它的位置(user_service.send_welcome_email),不是定义处
  • assert_called_once_with 验证"交互",比只看返回值更完整

4. 异步 Mock:AsyncMock

from unittest.mock import AsyncMock
import pytest

@pytest.fixture
def fake_redis():
    r = AsyncMock()
    r.get.return_value = "100"     # await r.get(...) -> "100"
    return r

async def test_stats(fake_redis):
    assert int(await fake_redis.get("count:abc")) == 100

要点:异步函数必须用 AsyncMock;对普通 MagicMock 做 await 会直接报错——这本身就是一种保护,防止忘了标记异步。

5. respx:拦截 httpx 请求

uv add --dev respx
import httpx
import respx

@respx.mock
async def test_fetch_exchange_rate():
    respx.get("https://api.fx.test/rate").respond(json={"rate": 7.2})
    async with httpx.AsyncClient() as client:
        r = await client.get("https://api.fx.test/rate")
    assert r.json()["rate"] == 7.2

要点:可以进一步用 respx.get(...).mock(return_value=httpx.Response(500)) 模拟失败路径——重试逻辑只有靠 mock 失败才能测到。

✅ 最佳实践

先明确依赖契约再替换:输入什么,可能返回哪些形状,什么时候抛错。断言返回结果或副作用,再在“必须恰好调用一次”属于业务承诺时检查调用次数,避免只演练 mock 脚本。

FastAPI 的 dependency_overrides 可替换依赖,但测试结束要恢复,防止污染其他用例。patch 则要作用在被测代码实际查找名称的位置。为替身加入一次失败返回,确认被测逻辑确实处理它。

❓ 常见问题

Q1: patch 之后测试通过但线上失败? 常见原因是 mock 返回形状与真实 API 不符。用真实响应快照做 mock 数据,并在 CI 里定期跑少量"真连"冒烟测试。

Q2: monkeypatch 和 mocker 用哪个? monkeypatch 适合环境变量与简单属性替换;mocker(pytest-mock)适合创建带交互验证的 Mock 对象。两者都会自动还原。


🔗 相关文档

练习与验收:让替身同时模拟成功与失败

为异步汇率服务建立 AsyncMock:一次返回符合契约的数据,一次抛超时,一次返回缺字段结果。预期正常路径计算正确,超时与非法响应进入不同的错误处理;使用 assert_awaited 验证确实等待异步调用,而非只创建协程。验收在被测模块实际查找名称的位置 patch,结束后恢复依赖,避免下个测试继续使用替身。

以上是在个人或隔离测试环境中的练习,不是本轮已执行记录;实际运行范围见仓库文档质量报告。

阅读导航

本模块理解地图 · 完整目录与版本 · 通用术语