前端转 AI 第 18 天:pytest 入门 - 为什么你必须要写测试
昨日回顾
Day 17 用 SQLAlchemy 重写了存储层,代码从 150 行降到 80 行,还清楚了关系映射。
但有个隐患------改了这么多代码,我怎么知道功能没坏?
手动跑一遍?每次改都跑一遍?Agent 项目模块一多,手动测不现实。
今天上测试。
Day 18 目标:搞懂 pytest 的核心用法,会给 Day 17 的存储层写测试。
一、为什么 Agent 开发者必须会写测试
前端同学对测试的态度常常是"重要但不紧急"------项目催得紧,先跑起来再说。
但做 Agent,测试不是"重要",是"生死攸关"。原因有三:
1. LLM 输出是不确定的
你今天写了个 prompt,模型输出符合预期。明天同样的输入,模型输出变了。
如果每个改动都靠"手动聊两句"验证,你根本没法迭代。
2. Agent 是"链式"的,改一处崩全身
erlang
用户输入 → 记忆检索 → Prompt 组装 → LLM 调用 → 输出解析 → 工具调用 → ...
每一环都可能出 bug。 改一个环节,要保证其他环节没受影响。
3. 调试成本高
Agent 跑一次 LLM 调用要几秒、几块钱。测试必须"能脱离模型跑",否则开发节奏会被拖死。
这就是"单元测试"存在的意义------把每个环节隔离出来,快速验证。
二、pytest 是什么
Python 有内置的 unittest,但pytest 是事实标准。为什么?
| 对比 | unittest | pytest |
|---|---|---|
| 写法 | 必须继承 TestCase 类 |
直接写函数 |
| 断言 | self.assertEqual(a, b) |
assert a == b |
| 失败信息 | 输出长 | 自动展开对比 |
| 参数化 | 靠循环 | @pytest.mark.parametrize |
| 夹具 | setUp / tearDown |
@pytest.fixture |
| 插件生态 | 一般 | 极其丰富 |
一句话:pytest 写起来像普通函数,看起来像自然语言。
安装
bash
pip install pytest pytest-cov pytest-asyncio
pytest:核心pytest-cov:覆盖率统计pytest-asyncio:测异步函数(Agent 必备)
三、第一个测试
1. 最简单的例子
新建 day18/hello_test/test_calc.py:
python
def add(a, b):
return a + b
def test_add():
assert add(1, 2) == 3
def test_add_negative():
assert add(-1, -2) == -3
运行:
bash
cd day18/hello_test
pytest
输出:
ini
==================== test session starts ====================
collected 2 items
test_calc.py .. [100%]
===================== 2 passed in 0.01s ====================
注意几点:
- 文件名必须
test_*.py或*_test.py(pytest 靠这个发现测试) - 函数名必须
test_*(同上) - 断言用
assert(不用self.assertEqual)
2. 失败的输出长什么样
改一个:
python
def test_add():
assert add(1, 2) == 4 # 故意写错
运行:
ini
==================== FAILURES ====================
____________________ test_add ____________________
def test_add():
> assert add(1, 2) == 4
E assert 3 == 4
E + where 3 = add(1, 2)
test_calc.py:6: AssertionError
================= short test summary info =================
FAILED test_calc.py::test_add - assert 3 == 4
================= 1 failed, 1 passed in 0.05s =============
pytest 自动把 add(1, 2) 展开成 3,一眼看出哪里错了。 这就是它比 unittest 受欢迎的核心原因。
3. 常用断言写法
python
# 基础
assert a == b
assert a != b
assert a > b
assert a is None
assert a is not None
# 容器
assert 1 in [1, 2, 3]
assert "key" in {"key": "value"}
assert len([1, 2, 3]) == 3
# 近似
import pytest
assert 0.1 + 0.2 == pytest.approx(0.3) # 浮点比较
# 异常
with pytest.raises(ValueError):
int("abc")
with pytest.raises(ValueError, match="invalid literal"):
int("abc")
pytest.raises 是测试异常的标准写法 ,比 try/except 简洁。
四、夹具 fixture:测试的"准备工作"
测试常常需要准备环境:连接数据库、创建临时文件、造测试数据。
这些重复代码放在 fixture 里,pytest 会帮你自动注入。
1. 最简单的 fixture
python
import pytest
@pytest.fixture
def sample_user():
"""返回一个测试用户"""
return {"name": "张三", "age": 25}
def test_user_name(sample_user):
assert sample_user["name"] == "张三"
def test_user_age(sample_user):
assert sample_user["age"] == 25
用法:把 fixture 名字作为参数写进测试函数,pytest 自动注入。
前端类比: 有点像 Jest 的 beforeEach,但更灵活------用哪个就传哪个。
2. 清理现场:yield
如果 fixture 里需要"用完清理"(比如临时文件):
python
import pytest
from pathlib import Path
@pytest.fixture
def temp_file(tmp_path):
"""创建临时文件,测试后自动清理"""
file = tmp_path / "test.txt"
file.write_text("hello", encoding="utf-8")
yield file
# 出了 yield,测试结束,可以在这里清理
# tmp_path 是 pytest 内置的,会自动清理,这里不用手动删
yield 之前是"准备",yield 之后是"清理"。
Python 里的 yield 语法(生成器)在这里又派上用场了。
3. tmp_path:pytest 的内置 fixture
pytest 自带一堆实用 fixture:
| fixture | 作用 |
|---|---|
tmp_path |
临时目录(Path 对象) |
tmp_path_factory |
批量临时目录 |
monkeypatch |
运行时替换属性 / 环境变量 |
capsys |
捕获 stdout / stderr |
caplog |
捕获日志 |
其中 tmp_path 最常用:
python
def test_write_file(tmp_path):
file = tmp_path / "data.txt"
file.write_text("hello")
assert file.read_text() == "hello"
每个测试都有独立的 tmp_path,互不干扰,用完自动删。
4. conftest.py:全局 fixture
如果多个测试文件需要同一个 fixture ,放在 conftest.py 里,pytest 会自动发现,不用 import:
python
# conftest.py
import pytest
@pytest.fixture
def db():
"""测试用的数据库"""
from myapp.db import Database
db = Database(":memory:") # 内存数据库
yield db
db.close()
任何测试文件都能直接用 db fixture。 不用 import,这是 pytest 的魔法。
五、参数化:一个测试跑多组数据
最常用的场景: 同一段逻辑,要测多种输入。
python
import pytest
def is_even(n):
return n % 2 == 0
# ❌ 笨办法:写三个测试
def test_even_2():
assert is_even(2)
def test_even_4():
assert is_even(4)
def test_even_6():
assert is_even(6)
# ✅ 参数化
@pytest.mark.parametrize("input,expected", [
(2, True),
(4, True),
(6, True),
(1, False),
(3, False),
(0, True),
])
def test_is_even(input, expected):
assert is_even(input) == expected
运行效果:
arduino
test_even.py::test_is_even[2-True] PASSED
test_even.py::test_is_even[4-True] PASSED
test_even.py::test_is_even[6-True] PASSED
test_even.py::test_is_even[1-False] PASSED
test_even.py::test_is_even[3-False] PASSED
test_even.py::test_is_even[0-True] PASSED
6 组数据,一个函数搞定。 失败时 pytest 会告诉你具体是哪一组。
前端类比: Jest 的 test.each,思路一模一样。
六、测异步代码
Agent 里大量异步函数,必须会测。
bash
pip install pytest-asyncio
加一个配置 (项目根目录 pytest.ini 或 pyproject.toml):
ini
# pytest.ini
[pytest]
asyncio_mode = auto
或者 pyproject.toml:
toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_mode = auto 表示"自动识别 async 测试函数",不用每个都加装饰器。
测试异步函数:
python
import asyncio
async def fetch_data():
await asyncio.sleep(0.1)
return "data"
async def test_fetch_data():
result = await fetch_data()
assert result == "data"
就这么简单。 pytest 会自动处理事件循环。
不加
asyncio_mode = auto的话 ,每个测试要加@pytest.mark.asyncio装饰器。自动模式更省心。
七、Mock:隔离外部依赖
测试的黄金法则:单元测试不应该真的去调 LLM API。
理由:
- 慢:每次几秒
- 贵:每次几块钱
- 不稳定:网络波动、限流、模型版本更新
解决:用 unittest.mock 替换掉外部调用。
1. 简单 mock
python
from unittest.mock import Mock
def test_mock_basic():
# 创建一个假的函数
fake_llm = Mock()
fake_llm.chat.return_value = "假的回复"
# 用它
result = fake_llm.chat("你好")
assert result == "假的回复"
# 断言"被调用过"
fake_llm.chat.assert_called_once_with("你好")
2. 替换模块内的依赖
真实场景: 你要测 Agent.process(),但它内部调了 LLMClient.chat()。
python
# agent.py
class Agent:
def __init__(self, llm):
self.llm = llm
def process(self, text):
reply = self.llm.chat(text)
return f"Agent: {reply}"
测试:
python
from unittest.mock import Mock
def test_agent_process():
fake_llm = Mock()
fake_llm.chat.return_value = "你好呀"
agent = Agent(fake_llm)
result = agent.process("你好")
assert result == "Agent: 你好呀"
fake_llm.chat.assert_called_once_with("你好")
这就是"依赖注入"的价值------Agent 不直接 new LLM,而是从外面传进来。测试时传个 Mock 就行。
前端类比: 前端做组件测试时,用 jest.mock() 替换 API 层。思路一样。
3. patch:替换模块级对象
如果函数里直接 import 了外部模块:
python
# service.py
import requests
def fetch_user(user_id):
r = requests.get(f"https://api.example.com/users/{user_id}")
return r.json()
测试时不想真的发请求:
python
from unittest.mock import patch
@patch("service.requests.get")
def test_fetch_user(mock_get):
mock_get.return_value.json.return_value = {"id": 1, "name": "张三"}
result = fetch_user(1)
assert result["name"] == "张三"
mock_get.assert_called_once_with("https://api.example.com/users/1")
@patch("模块路径.属性名") 会在测试期间替换掉那个属性。
注意 patch 的路径 :
patch的是"使用的地方",不是"定义的地方" 。service.py里import requests,就要 patchservice.requests.get。
八、实战:给 Day 17 的存储层写测试
动手时间。 给 storage_orm.py 写一套完整的测试。
1. 项目准备
bash
cp -r day17/ai-assistant-v4 day18/ai-assistant-v5
cd day18/ai-assistant-v5
创建测试目录:
bash
mkdir -p tests
touch tests/__init__.py
2. 配置 pytest
pyproject.toml 里加:
toml
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
addopts = "-v --tb=short"
含义:
testpaths:从哪里找测试asyncio_mode:自动识别 asyncaddopts:-v详细输出,--tb=short异常堆栈简洁
3. tests/conftest.py:全局 fixture
python
"""pytest 全局配置和 fixture"""
import sys
from pathlib import Path
import pytest
# 把 src 加到路径
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from ai_assistant.db_orm import DatabaseORM
from ai_assistant.storage_orm import SessionStorageORM
from ai_assistant.models import Session, Message
@pytest.fixture
def db(tmp_path):
"""测试用的内存数据库"""
# SQLite 内存数据库:`:memory:`
# 或者用 tmp_path 下的文件(更接近真实场景)
db_file = tmp_path / "test.db"
database = DatabaseORM(db_file)
yield database
database.close()
@pytest.fixture
def storage(db):
"""存储对象"""
return SessionStorageORM(db)
@pytest.fixture
def sample_session():
"""一个标准的测试会话"""
s = Session(session_id="test-001", title="测试会话")
s.messages = [
Message(role="user", content="你好"),
Message(role="assistant", content="你好呀"),
Message(role="user", content="Python 是什么"),
]
return s
@pytest.fixture
def empty_session():
"""空会话"""
return Session(session_id="test-empty", title="空会话")
关键点:
- 用
tmp_path而不是:memory::tmp_path更接近真实文件,能暴露文件权限等问题 yield保证清理 :测试结束自动close()sample_session/empty_session是"标准测试数据":避免每个测试里重复造
4. tests/test_storage.py
python
"""测试存储层"""
import pytest
from ai_assistant.models import Session, Message
class TestSaveAndLoad:
"""测试保存和加载"""
def test_save_new_session(self, storage, sample_session):
"""保存新会话"""
storage.save(sample_session)
loaded = storage.load(sample_session.session_id)
assert loaded is not None
assert loaded.session_id == sample_session.session_id
assert loaded.title == sample_session.title
assert len(loaded.messages) == 3
def test_save_creates_database_record(self, storage, sample_session):
"""保存后数据库里真的有记录"""
storage.save(sample_session)
rows = storage.db.session().__enter__().execute(
"SELECT COUNT(*) FROM sessions"
).scalar()
# 简化:直接再查一次
loaded = storage.load(sample_session.session_id)
assert loaded is not None
def test_load_nonexistent(self, storage):
"""加载不存在的会话"""
result = storage.load("nonexistent-id")
assert result is None
def test_save_empty_session(self, storage, empty_session):
"""保存空会话"""
storage.save(empty_session)
loaded = storage.load(empty_session.session_id)
assert loaded is not None
assert len(loaded.messages) == 0
def test_save_twice_overwrites(self, storage, sample_session):
"""重复保存会覆盖"""
storage.save(sample_session)
# 修改后保存
sample_session.add_message("user", "新消息")
storage.save(sample_session)
loaded = storage.load(sample_session.session_id)
assert len(loaded.messages) == 4
class TestListSessions:
"""测试会话列表"""
def test_list_empty(self, storage):
sessions = storage.list_sessions()
assert sessions == []
def test_list_multiple(self, storage, sample_session, empty_session):
storage.save(sample_session)
storage.save(empty_session)
sessions = storage.list_sessions()
assert len(sessions) == 2
def test_list_order(self, storage):
"""按更新时间倒序"""
import time
s1 = Session(session_id="s1", title="早的")
storage.save(s1)
time.sleep(0.01)
s2 = Session(session_id="s2", title="晚的")
s2.add_message("user", "hi")
storage.save(s2)
sessions = storage.list_sessions()
assert sessions[0]["id"] == "s2" # 更新的在前
class TestDelete:
"""测试删除"""
def test_delete_existing(self, storage, sample_session):
storage.save(sample_session)
result = storage.delete(sample_session.session_id)
assert result is True
loaded = storage.load(sample_session.session_id)
assert loaded is None
def test_delete_nonexistent(self, storage):
result = storage.delete("nonexistent")
assert result is False
def test_delete_cascades_messages(self, storage, sample_session):
"""删除会话时,消息也应删除(级联)"""
storage.save(sample_session)
storage.delete(sample_session.session_id)
# 直接查 messages 表确认
with storage.db.session() as s:
from sqlalchemy import select, func
from ai_assistant.orm_models import MessageModel
count = s.scalar(select(func.count(MessageModel.id)))
assert count == 0
class TestSearch:
"""测试搜索"""
def test_search_finds_keyword(self, storage, sample_session):
storage.save(sample_session)
results = storage.search("Python")
assert len(results) == 1
assert results[0]["id"] == sample_session.session_id
def test_search_no_result(self, storage, sample_session):
storage.save(sample_session)
results = storage.search("不存在的关键词xyz")
assert results == []
@pytest.mark.parametrize("keyword,should_find", [
("Python", True),
("你好", True),
("助手", True),
("Java", False),
("无关内容", False),
])
def test_search_multiple_keywords(self, storage, sample_session, keyword, should_find):
storage.save(sample_session)
results = storage.search(keyword)
assert (len(results) > 0) == should_find
class TestMessageOrdering:
"""测试消息顺序"""
def test_messages_kept_in_order(self, storage):
s = Session(session_id="order-test")
for i in range(5):
s.add_message("user", f"消息 {i}")
storage.save(s)
loaded = storage.load("order-test")
for i, msg in enumerate(loaded.messages):
assert msg.content == f"消息 {i}"
class TestDataIntegrity:
"""测试数据完整性"""
def test_empty_content_rejected(self, storage):
"""空内容应该被 Pydantic 拦住"""
from pydantic import ValidationError
with pytest.raises(ValidationError):
Message(role="user", content="")
def test_invalid_role_rejected(self, storage):
from pydantic import ValidationError
with pytest.raises(ValidationError):
Message(role="hacker", content="test")
5. 运行测试
bash
cd day18/ai-assistant-v5
pytest
输出:
arduino
tests/test_storage.py::TestSaveAndLoad::test_save_new_session PASSED [ 5%]
tests/test_storage.py::TestSaveAndLoad::test_save_creates_database_record PASSED [ 10%]
tests/test_storage.py::TestSaveAndLoad::test_load_nonexistent PASSED [ 15%]
tests/test_storage.py::TestSaveAndLoad::test_save_empty_session PASSED [ 21%]
tests/test_storage.py::TestSaveAndLoad::test_save_twice_overwrites PASSED [ 26%]
tests/test_storage.py::TestListSessions::test_list_empty PASSED [ 31%]
tests/test_storage.py::TestListSessions::test_list_multiple PASSED [ 36%]
tests/test_storage.py::TestListSessions::test_list_order PASSED [ 42%]
tests/test_storage.py::TestDelete::test_delete_existing PASSED [ 47%]
tests/test_storage.py::TestDelete::test_delete_nonexistent PASSED [ 52%]
tests/test_storage.py::TestDelete::test_delete_cascades_messages PASSED [ 57%]
tests/test_storage.py::TestSearch::test_search_finds_keyword PASSED [ 63%]
tests/test_storage.py::TestSearch::test_search_no_result PASSED [ 68%]
tests/test_storage.py::TestSearch::test_search_multiple_keywords[Python-True] PASSED [ 73%]
tests/test_storage.py::TestSearch::test_search_multiple_keywords[你好-True] PASSED [ 78%]
tests/test_storage.py::TestSearch::test_search_multiple_keywords[助手-True] PASSED [ 84%]
tests/test_storage.py::TestSearch::test_search_multiple_keywords[Java-False] PASSED [ 89%]
tests/test_storage.py::TestSearch::test_search_multiple_keywords[无关内容-False] PASSED [ 94%]
tests/test_storage.py::TestMessageOrdering::test_messages_kept_in_order PASSED [100%]
==================== 19 passed in 1.32s ====================
19 个测试,1.3 秒跑完。 这就是自动化测试的威力------改一行代码,立刻知道有没有搞坏别的功能。
6. 覆盖率报告
bash
pytest --cov=src/ai_assistant --cov-report=term-missing
输出:
matlab
Name Stmts Miss Cover Missing
---------------------------------------------------------------------
src/ai_assistant/__init__.py 0 0 100%
src/ai_assistant/config.py 15 2 87% 24-25
src/ai_assistant/db_orm.py 25 2 92% 45-46
src/ai_assistant/models.py 35 0 100%
src/ai_assistant/orm_models.py 30 0 100%
src/ai_assistant/storage_orm.py 80 8 90% 45-52
src/ai_assistant/logger.py 20 20 0% ...
---------------------------------------------------------------------
TOTAL 205 32 84%
--cov=src/ai_assistant 只统计 src/ai_assistant 下的代码。
--cov-report=term-missing 显示哪几行没测到。
目标:核心业务逻辑(storage_orm.py)覆盖到 90%+。
不追求 100%------有些分支(比如异常清理逻辑)很难测,投入产出比低。
7. 给 pytest 加"测试专用"依赖
pyproject.toml:
toml
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-cov>=5.0.0",
"pytest-asyncio>=0.24.0",
]
安装:
bash
pip install -e ".[dev]"
-e 是"可编辑安装",.[dev] 表示"当前项目 + dev 可选依赖"。
从此以后,任何一台机器上 pip install -e ".[dev]" 就能一键装好所有依赖。
九、前端视角:pytest vs Jest/Vitest
做过前端测试的同学会觉得很亲切:
| 概念 | Jest / Vitest | pytest |
|---|---|---|
| 测试文件命名 | *.test.ts |
test_*.py |
| 测试函数 | test('...', () => {}) |
def test_xxx(): |
| 断言 | expect(x).toBe(y) |
assert x == y |
| 分组 | describe(...) |
class TestXxx |
| 前置准备 | beforeEach |
@pytest.fixture |
| 清理 | afterEach |
yield 后的代码 |
| 参数化 | test.each([...]) |
@pytest.mark.parametrize |
| Mock | jest.mock() |
unittest.mock.patch |
| 覆盖率 | jest --coverage |
pytest --cov |
| 异步 | async () => {} |
async def + pytest-asyncio |
核心心智完全一致:
- "准备 → 执行 → 断言 → 清理"四段式
- 隔离依赖、mock 外部
- 测试要快、要独立、要可重复
你前端的测试思维,直接迁移到 Python。
十、今日踩坑
- 文件名不是
test_*.py→ pytest 不发现它,跑了个 0 测试 - 没装
pytest-asyncio→ async 测试会被跳过或报错 asyncio_mode没配 → 每个 async 测试都要手动加装饰器tmp_path写成了tmpdir→ 老版本用tmpdir,新版本用tmp_path(Path对象)- fixture 用
return而不是yield→ 无法做清理 patch路径写错 → patch 的是"用它的地方",不是"定义它的地方"- 测试之间共享状态 → 一个测试改了数据库,影响下一个,要用独立 fixture
- 测试依赖网络 / LLM → 绝对不能,一定要 mock
assert 0.1 + 0.2 == 0.3→ 浮点数比较要用pytest.approx- 测试不写"为什么" → 名字要能说清楚"测的是什么",
test_save_empty_session比test1好一百倍
十一、今日总结
今天核心收获三件事:
- pytest 是函数式测试框架 :
assert+fixture+parametrize三件套走天下 - fixture 是"准备 + 清理"的封装 ,
conftest.py让 fixture 全局可用 - 单元测试必须 mock 外部依赖:LLM、网络、数据库文件都要隔离
一个感受:
测试不是"额外的负担",而是"能放心改代码的勇气来源"。
没有测试,你不敢重构------万一改坏了怎么办?
有了测试,你改完直接
pytest一下,绿了就安心提交。测试不是给"代码"写的,是给"未来的你"写的。
你的 Agent 项目现在有了完整的测试骨架。 后面加功能时,先写测试,再写代码 ------这就是 TDD(测试驱动开发),做 Agent 特别有效,因为每个环节的输出都是可验证的。
Day 18 完成度:✅
明日预告(Day 19)
- 测试的进阶技巧:
monkeypatch:运行时改环境变量和属性capsys:捕获标准输出caplog:捕获日志
- 集成测试 vs 单元测试
- 用
pytest-benchmark做性能测试 - 给 LLM 调用写 mock 测试
- 给 Day 14 的
MemoryManager写完整测试
最后
如果这篇对你有用,点个赞让我知道有人在看。
评论区互动: 你之前写测试吗?前端用什么?欢迎分享。
有问题评论区见,我们第 19 天继续。
100 天,第 19 天见。
附录:系列目录
已完成 ✅
| 阶段 | 天数 | 内容 |
|---|---|---|
| 基础 | 01-06 | Python 语法、数据结构、函数、OOP、文件、依赖管理 |
| 网络 | 07-08 | HTTP 请求、异步 asyncio |
| 工程 | 09-12 | 正则、装饰器、Pydantic、日志调试 |
| 项目 | 13-14 | 命令行 AI 助手 v1 / v2 |
| 复盘 | 15 | 阶段总结 |
| 数据 | 16-17 | SQLite 数据库、SQLAlchemy ORM |
| 测试 | 18 | pytest 入门 ✅ |
进行中 🎯
| 阶段 | 天数 | 内容 |
|---|---|---|
| 测试 | 19 | pytest 进阶:mock / 集成 / 性能 |
| 工具 | 20 | CLI 打包与发布 |
| 后端 | 21-30 | FastAPI + 完整服务端 |
| 大模型 | 31-45 | LLM API + Prompt 工程 |
| RAG | 46-60 | 向量数据库 + 知识库 |
| Agent | 61-80 | LangChain / 工具调用 |
| 实战 | 81-100 | 完整 Agent 项目上线 |
100 天后见,一起从切图仔变成 Agent 开发者。