测试是 AI 编程的安全带
原创 · 约 28 分钟阅读 · 阅读 --

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

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