本文来自《AI 应用开发课程》月份 1 课程文档,已整理为网站文章版本。
学习目标
学完本节后,你应当能够:
- 使用
pytest为月份 1 的核心逻辑写测试。 - 覆盖正常路径和异常路径。
- 理解为什么 AI 项目也需要传统测试。
前置知识
- 已完成工程化前 3 节
1. 为什么 AI 项目仍然要写测试
月份 1 中很多逻辑其实不依赖模型随机性,可以稳定测试:
- 配置读取
- 数据模型校验
- 工具执行器
- 请求参数转换
- 错误处理
如果这些基础逻辑不稳定,后面你会把“模型问题”和“程序问题”混在一起。
2. 第一个测试
假设你有如下函数:
def normalize_text(text: str) -> str:
return text.strip()
测试文件 tests/test_utils.py:
from app.utils import normalize_text
def test_normalize_text_should_strip_whitespace() -> None:
assert normalize_text(" hello ") == "hello"
运行:
uv run pytest -q
3. 异常路径测试
import pytest
from app.config import ensure_api_key
def test_ensure_api_key_should_raise_when_missing() -> None:
with pytest.raises(ValueError):
ensure_api_key(None)
这类测试非常重要,因为月份 1 后续大量逻辑都涉及失败路径。
4. 本月优先测试什么
必测
- 配置加载
- Pydantic 模型校验
- 工具执行器
- 纯函数工具
可后补
- 真实模型调用
- 复杂流式链路
原因:
- 真实模型调用成本高、稳定性弱
- 月份 1 先把确定性逻辑测起来
5. 推荐测试目录
tests/
├── test_config.py
├── test_models.py
└── test_utils.py
6. 写测试时的规则
- 测试名要表达行为
- 一个测试只验证一个重点
- 优先小而稳定
- 正常路径和异常路径都要覆盖
7. 实操任务
- 给
ChatRequest写一个正常实例化测试。 - 给
ChatRequest写一个非法temperature的异常测试。 - 给某个工具函数写一个正常路径测试。
8. 自测题
- 为什么月份 1 不应把所有精力都花在“测真实模型返回值”上?
- 为什么异常路径测试很重要?
- 什么样的测试算“最小但有价值”?
9. 作业与验收
验收标准:
- 项目里至少有
3个测试 - 同时覆盖正常路径和异常路径
uv run pytest -q能通过
10. 常见错误
- 测试文件名不规范
- 把多个断言堆进一个测试导致定位困难
- 把依赖外部网络的逻辑当成最先要写的测试
11. 本章与前文关系
前面的工程化章节让你已经具备:
- 项目骨架
- 配置系统
- 日志基础
- 结构化模型
现在需要补上最后一块底层能力:验证。也就是当你写完这些结构后,如何确认它们在改动后仍然可靠。
12. 本章在研发助手项目中的位置
研发助手项目到了第 4 周时,你至少会希望有测试覆盖这些稳定逻辑:
- 配置读取
- 数据模型约束
- 工具执行器
- 核心 service 返回结构
/health之类的最小接口行为
测试不是为了形式主义,而是为了让你能放心继续迭代项目。
13. 为什么 AI 项目仍然需要传统测试
很多人误以为:
模型输出本来就有不确定性,所以 AI 项目没法好好测。
这只对一部分逻辑成立。月份 1 中大量内容其实非常适合测试:
- 结构化模型是否合法
- 配置缺失时是否报错
- 工具执行器是否能找到工具
- 路由层是否返回正确状态码
也就是说,AI 项目中的“不确定性”主要在模型生成本身,而不是整个项目的全部逻辑。
14. 确定性逻辑和非确定性逻辑如何区分
确定性逻辑
相同输入,应该稳定得到相同输出。
例如:
normalize_text(" hello ") -> "hello"- 缺少 API Key 应抛出
ValueError temperature=3应被模型校验拦住
非确定性逻辑
相同输入,模型可能出现不同自然语言输出。
例如:
- 自由文本总结
- 创意生成
月份 1 的测试策略是:
- 先测确定性逻辑
- 对非确定性逻辑尽量测“结构和边界”,而不是执着于文本逐字相等
15. 错误示例 vs 正确示例
错误示例:把所有行为塞进一个巨型测试
def test_everything() -> None:
...
问题:
- 失败时难定位
- 可读性差
- 很难说明这个测试真正保障了什么
正确示例:一个测试只验证一个重点
def test_chat_request_should_reject_invalid_temperature() -> None:
...
这种命名方式能直接表达行为和预期。
16. 完整文件级示例:tests/test_models.py
"""测试月份 1 的统一数据模型。"""
import pytest
from pydantic import ValidationError
from app.models import ChatMessage, ChatRequest
def test_chat_message_should_create_successfully() -> None:
"""当 role 和 content 合法时,模型应能正确创建。"""
message = ChatMessage(role="user", content="请总结这个 diff")
assert message.role == "user"
assert message.content == "请总结这个 diff"
def test_chat_request_should_use_default_values() -> None:
"""当未显式传入 model 和 temperature 时,应使用默认值。"""
request = ChatRequest(
messages=[ChatMessage(role="user", content="hello")]
)
assert request.model == "deepseek-chat"
assert request.temperature == 0.2
def test_chat_request_should_reject_invalid_temperature() -> None:
"""当 temperature 超出范围时,应抛出校验错误。"""
with pytest.raises(ValidationError):
ChatRequest(
messages=[ChatMessage(role="user", content="hello")],
temperature=3.0,
)
17. 逐段解释这份完整示例
为什么测试名写得这么长
因为测试代码本质上也是文档。一个好的测试名,能让你几年后回来仍然知道它在验证什么。
为什么这里不用真实模型调用
因为这里的目标是验证数据模型,不是验证模型 API 服务质量。你应该尽量让测试聚焦。
为什么要同时测默认值和异常路径
因为月份 1 的很多 bug,恰恰出在“看似不会出问题的默认情况”与“边界值输入”。
18. 一个更接近工程逻辑的增强版测试示例
你还可以给纯函数工具写测试,例如:
from app.utils import normalize_text
def test_normalize_text_should_strip_whitespace() -> None:
assert normalize_text(" hello ") == "hello"
这类测试非常适合月份 1,因为它:
- 小
- 稳
- 快
- 能建立信心
19. 为什么月份 1 的测试重点不在模型输出文本
因为如果你一开始就把测试建立在真实模型的自由文本上,会碰到:
- 成本问题
- 稳定性问题
- 网络依赖问题
月份 1 正确的优先顺序是:
- 先测本地确定性逻辑
- 再测接口结构
- 最后才考虑少量集成级真实调用验证
20. 调试与排错:本章最常见问题
问题一:pytest 找不到测试
通常是命名不规范,或者当前目录不对。
问题二:测试导入失败
通常是项目结构或模块路径还没理顺,这往往暴露的是前面工程化基础没打稳。
问题三:测试写得过于依赖实现细节
好的测试更应验证行为,而不是把某个内部实现写死。
21. 本章完成后你应该具备的能力
完成本章后,你应当做到:
- 能为月份 1 的核心确定性逻辑写最小测试。
- 能区分应该测什么,不应该优先测什么。
- 能写出正常路径和异常路径测试。
- 能用测试帮助自己后续重构。
22. 如果你卡在这里,先回看哪几章
- 数据模型还没定稳:回看 03-pydantic与配置建模.md
- 项目结构还混乱:回看 01-uv与项目初始化.md
23. 从本章过渡到下一章的桥接说明
接下来进入 LLM API 与 Prompt 模块。
到这里,你已经完成月份 1 最重要的底层准备:会 Python、会组织项目、会管理配置、会建模、会写最小测试。现在终于可以把这些基础能力接到真正的模型调用上,而且不会一上来就被项目混乱拖垮。