跳转至

第6章 测试与质量

学习目标

  • 使用 pytest 编写单元测试与集成测试,掌握 fixture 与参数化
  • 运用 mock 与依赖覆盖隔离外部依赖(LLM、数据库、HTTP)
  • 测量测试覆盖率并设定合理的质量门禁
  • 配置 ruff、mypy 等工具保障代码静态质量
  • 设计 AI Agent 的分层测试策略

问题导入

AI Agent 系统的测试比传统服务更具挑战:它依赖外部 LLM(响应不确定、调用昂贵)、调用多个工具、涉及异步与流式逻辑。如果测试直接调用真实 LLM,既慢又贵且结果不稳定;如果不测试,Agent 在生产中的"幻觉"、工具调用错误、超时等问题会反复发生。如何在不依赖真实外部服务的前提下,快速、稳定、全面地验证 Agent 行为,是工程化的核心难题。

本章将以 pytest 为核心,结合 FastAPI 的 TestClient/AsyncClient、依赖覆盖、mock 等手段,构建一套覆盖单元、集成、契约三个层次的测试体系,并引入 ruff(代码风格)、mypy(类型检查)、coverage(覆盖率)作为质量门禁,让 Agent 代码"敢改、能改、改得放心"。

核心概念

pytest 基础与 fixture

pytest 是 Python 事实标准的测试框架。测试函数以 test_ 开头,断言用原生 assert(无需 self.assertEqual)。fixture 是 pytest 的依赖注入机制:用 @pytest.fixture 定义可复用的前置数据/资源,通过参数名注入到测试函数。fixture 支持 scope(function/class/module/session)控制生命周期,params 实现参数化,yield 实现 setup/teardown。fixture 可依赖其他 fixture,构建分层测试上下文。

参数化测试

@pytest.mark.parametrize 让一个测试函数用多组输入运行多次,是"用少量代码覆盖大量场景"的利器。适合校验边界值、等价类、异常路径。与 fixture 的 params 不同,parametrize 直接作用于测试函数,更直观。

mock 与依赖隔离

单元测试应隔离被测单元与其外部依赖。unittest.mock 提供 Mock、MagicMock、patch。patch 可临时替换模块中的对象(如 patch("mymodule.httpx.AsyncClient")),测试后自动恢复。在 FastAPI 中,更优雅的方式是 app.dependency_overrides[dep] = fake_dep,将真实依赖(数据库、鉴权)替换为桩,无需 patch 内部实现。对 LLM,通常抽象出 LLMClient 接口,测试时注入返回固定内容的假客户端。

覆盖率

coverage.py(pytest-cov)测量测试执行了多少代码行/分支。pytest --cov=agent --cov-report=term-missing --cov-branch 输出行与分支覆盖率及未覆盖行号。覆盖率是"必要非充分"指标:高覆盖率不等于高质量(可能只覆盖路径未断言),但低覆盖率一定意味着存在未测试风险。建议设定门禁阈值(如 80%),并在 CI 中失败低于阈值的提交。

静态质量工具

  • ruff:用 Rust 编写的极速 linter + formatter,集成 flake8、isort、pyupgrade 等数十种工具的能力,可一键修复。配合 ruff format 替代 black。
  • mypy:静态类型检查器,依据类型提示发现类型错误,支持严格模式(--strict)。
  • pre-commit:在 git commit 前自动运行 ruff、mypy 等,把问题挡在提交前。 三者构成"风格—类型—逻辑"的静态质量防线。

原理图

flowchart TB
    subgraph 测试金字塔
        Unit[单元测试 70%<br/>mock 隔离 LLM/DB/HTTP]
        Integ[集成测试 20%<br/>TestClient + 内存 DB]
        E2E[端到端测试 10%<br/>真实 LLM 冒烟]
    end
    Unit --> Integ --> E2E
    subgraph 质量门禁 CI
        Cov[coverage 覆盖率≥80%]
        Ruff[ruff 风格检查]
        Mypy[mypy 类型检查]
        Pytest[pytest 全绿]
    end
    E2E --> Pytest
    Unit --> Pytest
    Integ --> Pytest
    Pytest --> Cov
    Pytest --> Ruff
    Pytest --> Mypy
    Ruff --> Merge[允许合并]
    Mypy --> Merge
    Cov --> Merge
图:测试金字塔分层 + CI 质量门禁,单元测试为主,静态工具兜底

示例代码

被测代码:一个简单的 Agent 服务

# agent_app.py
from dataclasses import dataclass
from typing import Protocol

class LLMClient(Protocol):
    async def complete(self, prompt: str) -> str: ...

@dataclass
class AgentService:
    llm: LLMClient

    async def reply(self, message: str) -> str:
        if not message.strip():
            raise ValueError("消息不能为空")
        prompt = f"用户: {message}\n助手:"
        return await self.llm.complete(prompt)

pytest 单元测试 + 参数化

# test_agent.py
import pytest
from unittest.mock import AsyncMock
from agent_app import AgentService

class FakeLLM:
    async def complete(self, prompt: str) -> str:
        return f"echo:{prompt}"

@pytest.mark.parametrize("message,expected_prefix", [
    ("你好", "echo:"),
    ("今天天气如何", "echo:"),
    ("AI Agent 是什么", "echo:"),
])
@pytest.mark.asyncio
async def test_reply(message: str, expected_prefix: str) -> None:
    agent = AgentService(llm=FakeLLM())
    result = await agent.reply(message)
    assert result.startswith(expected_prefix)
    assert message in result

@pytest.mark.asyncio
async def test_reply_empty_raises() -> None:
    agent = AgentService(llm=FakeLLM())
    with pytest.raises(ValueError, match="不能为空"):
        await agent.reply("   ")

@pytest.mark.asyncio
async def test_reply_uses_llm() -> None:
    # 用 mock 验证调用次数与参数
    mock_llm = AsyncMock()
    mock_llm.complete.return_value = "mocked"
    agent = AgentService(llm=mock_llm)
    result = await agent.reply("hi")
    assert result == "mocked"
    mock_llm.complete.assert_awaited_once()
    # 验证传给 LLM 的 prompt 包含用户消息
    called_prompt = mock_llm.complete.await_args.args[0]
    assert "hi" in called_prompt

fixture 与 conftest.py

# conftest.py
import pytest
from unittest.mock import AsyncMock
from agent_app import AgentService

@pytest.fixture
def fake_llm() -> AsyncMock:
    llm = AsyncMock()
    llm.complete.return_value = "fixed-reply"
    return llm

@pytest.fixture
def agent(fake_llm: AsyncMock) -> AgentService:
    return AgentService(llm=fake_llm)

# test_agent_fixture.py
import pytest

@pytest.mark.asyncio
async def test_with_fixture(agent: AgentService, fake_llm: AsyncMock) -> None:
    result = await agent.reply("hello")
    assert result == "fixed-reply"
    fake_llm.complete.assert_awaited_once()

FastAPI 集成测试(依赖覆盖)

# app.py —— 被测 FastAPI 应用(节选)
from fastapi import FastAPI, Depends
from pydantic import BaseModel

app = FastAPI()

class ChatBody(BaseModel):
    message: str

def get_llm():
    # 生产中返回真实 LLM 客户端
    from agent_app import AgentService
    return AgentService(llm=RealLLM())

@app.post("/chat")
async def chat(body: ChatBody, llm = Depends(get_llm)):
    reply = await llm.reply(body.message)
    return {"reply": reply}

# test_app.py —— 集成测试
import pytest
from unittest.mock import AsyncMock
from fastapi.testclient import TestClient
from app import app, get_llm

@pytest.fixture
def client():
    fake = AsyncMock()
    fake.reply.return_value = "fake-reply"
    # 覆盖依赖,无需真实 LLM
    app.dependency_overrides[get_llm] = lambda: fake
    yield TestClient(app)
    app.dependency_overrides.clear()

def test_chat_endpoint(client: TestClient) -> None:
    resp = client.post("/chat", json={"message": "你好"})
    assert resp.status_code == 200
    assert resp.json() == {"reply": "fake-reply"}

def test_chat_validation(client: TestClient) -> None:
    # 缺少 message 字段,应 422
    resp = client.post("/chat", json={})
    assert resp.status_code == 422

配置与运行

# pytest.ini
[pytest]
asyncio_mode = auto
testpaths = tests
addopts = -ra --strict-markers

# pyproject.toml —— ruff & mypy 配置
[tool.ruff]
line-length = 100
target-version = "py311"

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]

[tool.mypy]
python_version = "3.11"
strict = true
ignore_missing_imports = true

[tool.coverage.run]
branch = true
source = ["agent"]

[tool.coverage.report]
fail_under = 80
show_missing = true
# 运行测试与覆盖率
pytest --cov=agent --cov-report=term-missing
# 静态检查
ruff check . && ruff format --check .
mypy agent/

企业案例

案例 1:AI Agent 测试策略

某团队为 Agent 设计三层测试:单元层用 FakeLLM(按 prompt 模式匹配返回固定文本)覆盖 prompt 构造、工具选择、错误处理逻辑,毫秒级运行;集成层用 FastAPI TestClient + 依赖覆盖验证 API 契约与校验;端到端层每晚用真实 LLM 跑一组"冒烟用例"(10 个典型问题),用断言"回答包含某关键词"而非完全匹配。CI 门禁:单元+集成全绿且覆盖率 ≥ 85% 方可合并。

案例 2:LLM 调用的录制回放

为兼顾真实性与稳定性,团队用 pytest-vcr 或自研录制机制:首次调用真实 LLM 时录制请求/响应到 fixtures,后续测试回放录制数据,既贴近真实又消除网络与不确定性。LLM 模型升级时删除录制重新生成,作为升级评审的一部分。

案例 3:ruff + mypy 统一质量基线

团队引入 ruff 替换 flake8+isort+black 三件套,lint 时间从 30s 降至 2s;mypy 严格模式接入 CI 后,半年内类型相关 bug 下降 50%。通过 pre-commit 在本地拦截,避免低质量代码进入远程仓库,代码评审聚焦设计而非格式。

常见问题

Q1:测试该测到什么粒度?覆盖率该多高? 应遵循测试金字塔:大量单元测试覆盖核心逻辑,少量集成测试验证组件协作,极少数端到端测试。覆盖率建议 70%~85% 作为起点,关键模块(鉴权、计费、Agent 编排)应更高。不要盲目追求 100%,过度测试反而增加维护成本。

Q2:异步测试怎么写?需要 pytest-asyncio 吗? 需要。安装 pytest-asyncio,用 @pytest.mark.asyncio 标记或在配置中设 asyncio_mode = auto。测试函数写为 async def,内部可 await。注意 fixture 也可为异步(@pytest_asyncio.fixture)。

Q3:mock 和依赖覆盖哪种更好? FastAPI 场景优先依赖覆盖(dependency_overrides):它替换的是"接口边界",不依赖内部实现细节,重构时不易失效。对纯 Python 模块的内部依赖,用 unittest.mock.patch。对 LLM 等外部服务,优先设计接口(Protocol)并注入假实现,比 mock 更稳健、更可读。

Q4:如何测试流式(SSE)接口? 用 httpx.AsyncClient 的 stream 模式或 TestClient 的 stream 上下文,逐行读取响应并断言。对于 SSE,断言 data: 行的内容序列。也可用 respx mock httpx 的流式响应,避免真实网络。

面试题

题1:解释 pytest fixture 的 scope 及其影响。 参考答案:scope 决定 fixture 实例的生命周期:function(默认,每个测试新建)、class(每个类一次)、module(每个模块一次)、session(整个测试会话一次)。scope 越大复用越多、速度越快,但共享状态可能带来测试间污染。需在速度与隔离性间权衡,对昂贵资源(如启动服务)用大 scope,对易变数据用 function scope。

题2:什么是"测试金字塔"?为什么端到端测试不宜过多? 参考答案:金字塔指大量单元测试为底、集成测试为中、端到端测试为顶的分层结构。端到端测试虽贴近真实但慢、贵、不稳定、定位困难;过多会导致反馈慢、维护成本高。应以快速隔离的单元测试为主,端到端仅覆盖关键路径。

题3:如何 mock 一个 async 函数? 参考答案:用 unittest.mock.AsyncMock,它使方法支持 await 并记录 assert_awaited、assert_awaited_once 等异步调用断言。也可用 patch(..., new_callable=AsyncMock)。注意 return_value 与 side_effect 同样适用。

题4:mypy strict 模式会检查哪些东西? 参考答案:包括:所有函数必须有类型注解(disallow_untyped_defs)、不允许 Any(disallow_any_*)、不允许未类型化的装饰器、严格可选(None 需显式 Optional)、警告未使用 ignore 等。它最大化类型安全,但要求代码注解完整。

练习题

  1. 上机:为本章 AgentService 编写参数化测试,覆盖正常消息、空消息、超长消息(>4000 字符应抛错)三种场景,用 pytest.mark.parametrize 实现,并运行 pytest --cov 查看覆盖率。

  2. 思考:若 Agent 依赖一个返回不确定内容的真实 LLM,你会如何设计测试断言?请给出至少 3 种"非精确匹配"的断言策略(如包含关键词、格式校验、长度范围)。

  3. 上机:为第4章的 /chat 接口编写集成测试,用 app.dependency_overrides 替换 LLM 依赖为返回固定内容的桩,验证:① 正常请求返回 200;② 缺少字段返回 422;③ 鉴权失败返回 401。

小结

测试与质量保障是 AI Agent 从"demo"走向"产品"的分水岭。pytest 的 fixture/参数化/mock 提供了强大的测试能力,依赖覆盖让 FastAPI 集成测试轻量而稳健,coverage/ruff/mypy 构成静态与动态的质量门禁。遵循测试金字塔、隔离外部依赖、设定合理门禁,才能让 Agent 代码长期可演进。延伸阅读:pytest 文档、ruff 文档、mypy 文档、Coverage.py。