当前位置: 首页 > news >正文

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 生成物只是一次看起来不错的回答。有了测试,它才有机会进入工程体系,被别人接手、重构、发布和长期维护。

安全带不是为了开慢车。安全带是为了你可以放心把车开上路。

阅读原文

http://www.cnnetsun.cn/news/3521715.html

相关文章:

  • 【Claude企业级部署安全白皮书】:从API密钥隔离到上下文泄露防护,金融级合规实践首次公开
  • TMS320F2838x时钟与安全监控:从架构解析到高可靠系统设计实战
  • 如何5分钟快速上手SpringBoot操作日志组件mzt-biz-log
  • 鸿蒙ArkUI实战:生成账本数据导出预览
  • C2000 ePWM高级功能解析:谷底开关、多模块同步与HRPWM实战
  • Priceline Design System 企业级应用案例:如何在大型项目中实施设计系统
  • Intern-S2-Preview-397B-FP8代理集成:连接OpenClaw和Hermes的终极指南
  • QobuzDownloaderX-MOD用户指南:自定义音乐下载路径与音质设置全攻略
  • 【2024最严苛AI模型功能评测】:从零代码微调到合规审计,仅2款模型通过全部19项企业级交付验收标准
  • 关于图论【力扣797.所有可能的路径的思考】
  • DingFlow:基于React的高效审批流程构建工具终极指南
  • 邮件系统错误处理与智能诊断方案Mail-Error详解
  • 如何快速安装Lyric-Getter:Xposed模块歌词获取完整教程
  • pgagroal源码解析:深入理解PostgreSQL高性能连接池的核心实现原理
  • 《持续交付2.0系列四》组织文化---从恐惧到信任
  • BiliScope终极指南:解锁B站深度浏览体验的免费神器
  • AI+医疗:从疾病诊断到新药研发,哪些领域已落地?
  • 3步掌握猫抓扩展:让网页视频下载变得像截图一样简单
  • 猫抓浏览器扩展:免费视频下载与资源嗅探的终极指南
  • 终极指南:3步在macOS上创建完美Windows启动盘
  • 猫抓浏览器资源嗅探扩展:基于HLS/DASH协议的流媒体捕获技术解析
  • grunt-contrib-copy 社区贡献指南:如何参与插件开发与维护
  • 解决iOS导航栏冲突难题:GKNavigationBarViewController高级用法详解
  • 3步极速瘦身:用Win11Debloat彻底清理Windows 11系统冗余
  • 猫抓浏览器插件:网页资源嗅探与下载的完整解决方案
  • BiliScope:你的B站智能助手终极指南
  • rules_foreign_cc跨平台指南:在Linux、macOS和Windows上的最佳实践
  • Barrier:创新跨平台键盘鼠标共享解决方案
  • Nemo Skills数学推理评估指南:AIME、AMC等10大数学竞赛数据集实战
  • 三伏轻补首选黄精,4组温润茶饮安稳度夏