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_cents1000, quantity2)], 0, 2000), ([Item(price_cents1000, quantity2)], 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_cents1000, quantity1)], -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(idu_1, statusactive, roleuser) pytest.fixture def paid_order(active_user): return Order(ido_1, user_idactive_user.id, statuspaid)测试就能写得更像规则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?limit20).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(statusrunning, locked_atminutes_ago(40)) await recover_stale_jobs(nowfreezer.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}]}, headersheaders) second client.post(/api/orders, json{items: [{sku: b, quantity: 1}]}, headersheaders) 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.idmock 适合隔离不可控外部系统比如支付、短信、第三方 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 生成物只是一次看起来不错的回答。有了测试它才有机会进入工程体系被别人接手、重构、发布和长期维护。安全带不是为了开慢车。安全带是为了你可以放心把车开上路。阅读原文