测试是 AI 编程的安全带
古董级程序员,大厂出来后一直在创业公司,现在仍在一线写代码、做产品、和 AI 编程工具较劲。更完整的更新会放在微信公众号「字与码」:工作经历、新技术观察、AI 开发实践,以及这些年踩过的坑,都会陆续写在那里。
《AI时代的程序员修养》从这一篇进入第三卷:让 AI 生成物进入工程体系。前面讲了程序、数据结构、接口、数据库、高并发,这些都是“怎么设计”。但设计再好,最后还是要落到代码。AI 写代码很快,快到人很容易放松警惕。
测试在 AI 编程里不是锦上添花,而是安全带。没有测试,AI 生成的代码就像一个看起来会跑的陌生模块:你不知道它哪里是假设,哪里是碰巧,哪里在边界条件下会塌。
会用 AI 写代码的人,应该更重视测试,而不是更轻视测试。
AI 最擅长写出“像真的”代码
AI 生成代码的危险,不在于它总是错。恰恰相反,它经常写得像真的。
函数名像真的,分层像真的,异常处理像真的,注释也像真的。它可能通过了最简单的手工验证,但在几个边界条件下悄悄错掉。
比如一个价格计算函数:
def calculate_total(items, discount_rate):
total = sum(item.price * item.quantity for item in items)
return total * (1 - discount_rate)
看起来没问题,但问题很多:
discount_rate能不能是负数?- 折扣能不能超过 1?
- 金额是不是浮点数?
quantity能不能是 0?- 结果怎么四舍五入?
- 是否有优惠封顶?
- 空列表返回 0 还是报错?
AI 写这段函数很快。人要做的是把这些规则变成测试。
如果没有测试,你只能靠读代码猜。测试的价值,是把“我以为”变成“这个输入必须得到这个输出”。
先写样例,再写实现
AI 编程里,我更推荐把“先写测试”理解成“先写样例”。不要一上来就让 AI 实现功能,而是让它先列输入、输出、边界和失败场景。
例如:
先不要写实现。
请为“订单总价计算”列出测试样例:
- 正常商品数量和单价。
- 空商品列表。
- 折扣为 0。
- 折扣为 1。
- 折扣小于 0。
- 折扣大于 1。
- 金额需要按分为单位,不能使用浮点数。
- 优惠金额有上限。
这一步会暴露需求是不是清楚。很多时候,AI 列完样例,你会发现产品规则还没定。
样例确认后,再让 AI 写实现和测试。这样代码是围绕规则长出来的,而不是围绕一句含糊需求长出来的。
测试可以长这样:
import pytest
@pytest.mark.parametrize(
("items", "discount_bps", "expected"),
[
([Item(price_cents=1000, quantity=2)], 0, 2000),
([Item(price_cents=1000, quantity=2)], 1000, 1800),
([], 0, 0),
],
)
def test_calculate_total(items, discount_bps, expected):
assert calculate_total(items, discount_bps) == expected
def test_reject_invalid_discount():
with pytest.raises(ValueError):
calculate_total([Item(price_cents=1000, quantity=1)], -1)
这里用 discount_bps,也就是万分比,避免浮点误差。测试逼着实现也变稳。
单测检查规则,不检查框架
单元测试最适合检查纯规则。
价格计算、状态迁移、权限判断、分页 cursor 编解码、重试退避、限流 token bucket、字段脱敏,这些都应该尽量写成不依赖数据库、不依赖网络、不依赖框架的函数或小对象。
比如订单状态迁移:
ALLOWED_TRANSITIONS = {
"pending": {"paid", "cancelled"},
"paid": {"shipped", "refunding"},
"shipped": {"completed"},
"cancelled": set(),
}
def can_transition(current: str, target: str) -> bool:
return target in ALLOWED_TRANSITIONS.get(current, set())
对应测试:
def test_pending_can_be_paid():
assert can_transition("pending", "paid")
def test_cancelled_cannot_be_paid():
assert not can_transition("cancelled", "paid")
def test_unknown_status_cannot_transition():
assert not can_transition("unknown", "paid")
这种测试跑得快,失败定位清楚,也最适合让 AI 反复改。
一个常见错误是让单测启动整个 Web 框架、连真实数据库、调真实外部服务。那就不是单测了。重了之后,大家就不爱跑,AI 改代码时也不容易快速验证。
让 AI 写单测时,可以明确:
请先把核心规则抽成纯函数。
单元测试不能访问数据库、网络、文件系统和真实时间。
外部依赖必须通过参数传入或 mock。
测试轻,迭代才快。
集成测试检查边界
单测检查规则,集成测试检查模块边界。
比如一个创建订单接口,单测可以测价格计算、库存扣减规则、状态迁移。集成测试要测:
- HTTP 请求 schema。
- 权限。
- 数据库事务。
- 唯一约束。
- 幂等键。
- 错误响应结构。
- outbox 是否写入。
集成测试可以使用测试数据库,但要保证可重复、可清理。
async def test_create_order_writes_outbox(client, db):
response = await client.post(
"/api/orders",
json={
"items": [{"sku": "book_001", "quantity": 1}],
"client_order_id": "c_001",
},
headers={"Idempotency-Key": "create-order:u1:c001"},
)
assert response.status_code == 200
body = response.json()
outbox = await db.fetch_one(
"select * from outbox where aggregate_id = :order_id",
{"order_id": body["id"]},
)
assert outbox["event_type"] == "order.created"
这里测试的不是某个函数,而是接口和数据库之间的承诺。
集成测试要控制数量。所有路径都用集成测试会很慢;关键路径、事务边界和跨模块契约才值得用集成测试守住。
Fixture 是测试的语言
测试里最容易烂的是数据准备。
如果每个测试都手写一大段用户、订单、商品、库存数据,测试会变得很吵。读测试的人看不到重点,只看到样板代码。
fixture 的作用,是把“给我一个有效用户”“给我一条已支付订单”“给我一个库存不足商品”变成测试语言。
@pytest.fixture
def active_user():
return User(id="u_1", status="active", role="user")
@pytest.fixture
def paid_order(active_user):
return Order(id="o_1", user_id=active_user.id, status="paid")
测试就能写得更像规则:
def test_paid_order_can_request_refund(paid_order):
assert can_request_refund(paid_order)
但 fixture 也会变坏。过度共享的大 fixture 会让测试依赖隐含状态,改一处影响一堆。
几个实用原则:
- fixture 名字表达业务状态。
- 不要用一个万能 fixture。
- 每个测试只拿自己需要的数据。
- fixture 默认生成有效对象。
- 边界和异常数据在测试里显式覆盖。
让 AI 写测试时,可以要求:
请先设计 fixture。
fixture 要表达业务语义,例如 active_user、paid_order、expired_token。
不要创建包含所有字段的万能 fixture。
每个测试只使用必要 fixture。
好的 fixture 会让测试像文档。
契约测试防接口被悄悄改坏
接口契约一旦有人依赖,就不能随便变。
契约测试不关心内部实现,只关心输入输出是否保持承诺。前面接口那篇讲过 schema、错误码、幂等、分页,这些都应该有契约测试。
比如错误响应:
def test_validation_error_contract(client):
response = client.post("/api/orders", json={"items": []})
assert response.status_code == 400
body = response.json()
assert set(body.keys()) == {"error", "request_id"}
assert body["error"]["code"] == "INVALID_ARGUMENT"
assert body["error"]["retryable"] is False
再比如分页契约:
def test_cursor_pagination_contract(client, seed_orders):
first = client.get("/api/orders?limit=20").json()
assert "items" in first
assert "page_info" in first
assert "next_cursor" in first["page_info"]
assert "has_more" in first["page_info"]
AI 很容易在重构时把 page_info 改成 pagination,把 retryable 漏掉,或者把空列表改成 null。契约测试就是防这些“看起来不大”的破坏。
让 AI 改接口时,可以先说:
改实现前先补契约测试。
已有响应字段不能删除或改名。
新增字段必须保持向后兼容。
错误响应结构不能变化。
契约测试是保护调用方,不是保护实现。
回归测试要从 bug 长出来
线上 bug 修完后,最重要的问题不是“代码改了吗”,而是“这个 bug 以后还会不会回来”。
回归测试应该从 bug 长出来。
比如曾经出现过:任务 worker 重启后,某些任务永远停在 running。修复代码可能是增加租约回收:
update jobs
set status = 'pending',
locked_by = null,
locked_at = null
where status = 'running'
and locked_at < now() - interval '30 minutes';
对应回归测试:
async def test_recover_stale_running_jobs(db, freezer):
job = await create_job(status="running", locked_at=minutes_ago(40))
await recover_stale_jobs(now=freezer.now())
refreshed = await get_job(job.id)
assert refreshed.status == "pending"
assert refreshed.locked_by is None
这条测试记录了一个真实事故。以后 AI 重构 worker 状态机,如果不小心删掉租约恢复逻辑,测试会拦住。
每个重要 bug 都值得问:
- 最小复现输入是什么?
- 哪个状态组合触发问题?
- 需要 mock 哪个外部依赖?
- 这个 bug 属于单测、集成测试还是端到端测试?
- 测试名能不能说清楚历史问题?
不要只让 AI “修一下”。要让它把 bug 变成测试。
故障样例比 happy path 更值钱
AI 很擅长写 happy path。工程质量主要看故障路径。
异步任务要测:
- 重复消息。
- 下游超时。
- 临时错误重试。
- 永久错误进死信。
- worker 崩溃后恢复。
接口要测:
- 参数缺失。
- 权限不足。
- 幂等键重复。
- 幂等键复用但请求体不同。
- 下游降级。
数据库要测:
- 唯一约束冲突。
- 并发扣减。
- 事务回滚。
- 乐观锁冲突。
高并发代码要测:
- 队列满。
- 连接池等待超时。
- 限流命中。
- 熔断打开。
比如测试幂等键复用:
def test_reject_same_idempotency_key_with_different_payload(client):
headers = {"Idempotency-Key": "create-order:u1:c001"}
first = client.post("/api/orders", json={"items": [{"sku": "a", "quantity": 1}]}, headers=headers)
second = client.post("/api/orders", json={"items": [{"sku": "b", "quantity": 1}]}, headers=headers)
assert first.status_code == 200
assert second.status_code == 409
assert second.json()["error"]["code"] == "IDEMPOTENCY_KEY_REUSED"
这种测试比“创建订单成功”更能约束系统。
让 AI 写测试计划时,可以要求:
happy path 最多占 30%。
请重点列故障样例、边界样例、并发样例和历史回归样例。
每个样例说明要保护的业务规则。
这会让 AI 从“写几个测试”转向“守住系统行为”。
Mock 要克制
mock 是必要的,但滥用 mock 会让测试变假。
如果一个测试把所有东西都 mock 掉,只验证某个函数被调用了一次,它可能完全不能证明业务正确。
坏味道:
payment_client.charge.assert_called_once()
email_sender.send.assert_called_once()
这只能证明调用发生了,不能证明状态正确、幂等正确、失败处理正确。
更好的测试是验证结果和可观察副作用:
assert order.status == "pending_payment"
assert outbox.event_type == "payment.requested"
assert outbox.payload["order_id"] == order.id
mock 适合隔离不可控外部系统,比如支付、短信、第三方 API、真实时间。不要 mock 自己正在测试的业务规则。
几个原则:
- mock 外部边界,不 mock 核心规则。
- 优先验证状态和输出,不只验证调用次数。
- mock 的返回要覆盖成功、超时、临时错误、永久错误。
- 不要让 mock 隐藏真实序列化、schema 和事务问题。
让 AI 写测试时,可以要求:
只能 mock 外部系统,不要 mock 当前模块的核心业务函数。
测试应验证输出、状态变化、数据库记录或 outbox,而不是只验证调用次数。
这能减少“测试很多,但没测到重点”的情况。
端到端测试少而硬
端到端测试最接近真实用户路径,但成本最高、速度最慢、最容易不稳定。
所以它应该少而硬。
适合端到端测试的场景:
- 登录到核心业务完成。
- 创建订单到支付状态回写。
- 文件上传到任务成功。
- 搜索到执行工具。
- 管理后台关键操作。
不适合把所有边界条件都塞进端到端测试。边界条件应该主要由单测、集成测试和契约测试覆盖。
一个健康测试结构大概是:
| 层级 | 数量 | 作用 |
|---|---|---|
| 单测 | 多 | 快速覆盖规则和边界 |
| 集成测试 | 中 | 覆盖数据库、接口、队列边界 |
| 契约测试 | 中 | 固定对外承诺 |
| 端到端测试 | 少 | 覆盖关键用户路径 |
测试金字塔不是教条,但方向对:越底层越多,越上层越少。
AI 写代码越快,越要避免端到端测试成为唯一防线。否则每次改动都慢,慢到最后没人跑。
测试也要进 prompt
让 AI 写功能时,可以把测试写进工作协议:
实现前先写测试计划。
测试计划必须包含:
1. 核心业务规则的单元测试。
2. 数据库事务和唯一约束的集成测试。
3. API 成功和错误响应的契约测试。
4. 至少 3 个故障样例。
5. 至少 1 个历史回归样例。
先给测试列表,不要写实现。
我确认后,再写测试代码和实现代码。
如果是修 bug:
先根据 bug 描述写一个失败测试。
确认测试失败后,再修改实现。
修复后测试必须通过,并说明为什么这个测试能防止问题回归。
这会显著提高 AI 编程的质量。因为 AI 不再只负责“把代码写出来”,它还要负责“证明代码符合预期”。
测试代码也要 review
测试不是自动正确的。AI 生成的测试同样要 review。
重点看:
- 测试名是否说明业务行为。
- 断言是否真的验证了结果。
- 是否只有 happy path。
- 是否 mock 过度。
- fixture 是否隐藏太多状态。
- 是否依赖执行顺序。
- 是否访问真实外部服务。
- 是否有时间、随机数、并发导致的不稳定。
- 是否只是为了覆盖率而覆盖。
一个坏测试:
def test_create_order(client):
response = client.post("/api/orders", json={...})
assert response.status_code == 200
它只证明没有报错。更好的测试要说明业务承诺:
def test_create_order_persists_pending_order_and_outbox_event(client, db):
response = client.post("/api/orders", json={...}, headers={...})
assert response.status_code == 200
order = db.get_order(response.json()["id"])
assert order.status == "pending_payment"
assert db.find_outbox("order.created", order.id) is not None
测试名、输入和断言应该能让人看懂:这个系统承诺了什么。
一份给 AI 的测试提示词
可以把这段作为固定提示:
先不要写实现。
请为这个需求设计测试方案:
1. 列出核心业务规则和对应单元测试。
2. 列出需要集成测试覆盖的数据库、队列、事务和接口边界。
3. 列出 API 契约测试,包括成功响应、错误响应、分页、幂等和权限。
4. 设计 fixture,要求名字表达业务状态。
5. 列出故障样例:超时、重复请求、权限不足、并发冲突、下游失败。
6. 如果是 bugfix,先写能复现 bug 的失败测试。
7. 说明哪些依赖可以 mock,哪些不能 mock。
8. 给出每个测试保护的业务承诺。
9. 最后再给实现计划。
这段提示词的核心是:先把行为钉住,再生成代码。
让结果可复现
AI 编程时代,代码生成越来越快,真正稀缺的是可复现的判断。
测试就是可复现的判断。
它告诉你:这个输入必须得到这个输出;这个错误必须返回这个码;这个任务重复执行不能产生两次副作用;这个 bug 修过以后不能回来。
没有测试,AI 生成物只是一次看起来不错的回答。有了测试,它才有机会进入工程体系,被别人接手、重构、发布和长期维护。
安全带不是为了开慢车。安全带是为了你可以放心把车开上路。
微信公众号
欢迎关注「字与码」
如果这篇文章对你有用,也欢迎在微信里继续关注后续更新。
X / Twitter
关注 @ax2_zicode
更即时的技术观察、新文章提醒和一些短想法会发在 X 上。