让 AI 编写与运行测试:从样例通过到回归验证

摘要

让 AI 编程助手"补几个测试"并不等于获得了可靠验证。测试可能只覆盖最容易通过的成功路径,断言可能没有检查真实结果,甚至可能因为测试本身写错而让错误实现变绿。

把测试任务交给 AI 时,应先确定需求场景和失败基线,再让助手编写能暴露缺陷的测试;修改实现后,按聚焦测试、回归测试、静态检查和差异审查逐层验证。测试通过只是证据链中的一环,还要确认测试确实执行、断言确实有效、环境限制确实被记录。

一、背景与问题

代码变更最容易被"看起来能运行"误导。一个函数返回了列表,测试只断言状态码为 200;一个分页接口返回了数据,测试只断言列表非空;一个权限接口没有泄露内容,测试却没有使用不同用户身份。这些测试数量不少,却不能证明需求成立。

AI 生成测试时还会出现另一类问题:它根据实现反推测试,而不是根据需求定义行为。实现里把默认页大小写成 20,测试就把 20 固定下来;实现错误地先分页再筛选,测试数据只有一页,错误自然不会暴露。

可靠测试需要回答三个问题:

  1. 如果当前实现有缺陷,测试是否会失败?
  2. 测试断言检查的是用户可观察行为,还是内部实现细节?
  3. 测试失败后,能否判断是代码、测试、环境还是需求契约出了问题?

二、核心概念

1. 测试是可执行的验收契约

把需求场景转换为测试用例时,至少保留四个要素:

要素 示例
前置状态 已登录用户拥有 3 条 done 任务
操作 请求 /api/tasks?status=done&page_size=2
期望行为 返回 2 条任务,total 为 3
保护边界 不返回其他租户任务,默认排序不改变

只写"调用接口并断言成功"会丢失大部分契约。测试名称、夹具和断言应让另一位开发者不看实现也能理解它保护的行为。

2. 测试层次

层次 关注点 反馈速度 适合 AI 完成的工作
单元测试 单个函数或类的规则 快 边界、异常、纯逻辑
集成测试 模块与数据库、队列或文件系统的协作 中 查询条件、事务、序列化
接口测试 从真实入口观察响应和权限 较慢 输入输出、错误包装、回归
端到端测试 多个服务和真实流程 慢 关键用户路径、发布冒烟

测试层次不是越高越好。把所有场景都写成端到端测试会降低反馈速度;只写单元测试又可能遗漏路由、依赖注入和数据库查询。先用快速测试锁住规则,再用少量集成或接口测试证明跨层行为。

3. 失败基线与回归测试

失败基线指修改实现前,测试在已知缺陷上确实失败。回归测试指修复后保留这条测试,防止同一个缺陷再次出现。没有失败基线,测试可能只是"为当前实现寻找合理解释"。

不一定要把整个仓库恢复到有 bug 的版本。可以在隔离分支、临时补丁或测试替身中验证断言是否有效,但最终要记录实际采用的方式和结果,不要把人工推演写成已运行测试。

三、工作原理

AI 编写与运行测试可以按以下循环推进:

text 复制代码
读取需求和现有测试
        ↓
列出场景矩阵:成功、异常、边界、权限、回归
        ↓
确认测试命令与环境依赖
        ↓
先写能暴露问题的测试,运行失败基线
        ↓
修改实现,运行聚焦测试
        ↓
运行完整回归、静态检查和差异审查
        ↓
汇报命令、退出状态、跳过项和剩余风险

每次测试失败都要保留完整错误信息。助手不应只看到最后一行 FAILED 就直接修改代码;先确认失败位置、输入数据、期望值、实际值和环境条件,再判断根因。

"测试全绿"也需要拆解:哪些测试执行了,哪些被跳过,是否使用了 mock,是否连接了真实数据库,是否覆盖了本次改动路径。绿灯是结果,不是过程证明。

四、实战示例

1. 从场景矩阵开始

继续使用任务列表筛选功能,先让 AI 生成场景矩阵:

text 复制代码
请根据任务列表接口契约设计测试,不要先看实现来决定期望结果。

必须覆盖:
- 不传参数,保持旧排序、默认页码和默认页大小;
- 传 status=done,只统计筛选后的 total;
- 筛选结果超过一页,验证第二页和剩余数量;
- 合法状态但没有匹配数据,返回空 items 和 total=0;
- 非法状态、page=0、page_size=101,沿用既有参数错误结构;
- 不同租户查询时,total 和 items 都不能越过租户边界;
- 原有任务列表接口测试全部保持通过。

先输出"场景 → 输入 → 期望 → 测试层次 → 测试数据",确认后再编辑测试文件。

如果 AI 把"状态值"直接从实现枚举中复制出来,要继续要求它核对产品或接口契约;实现中的拼写不一定就是对外约定。

2. 写出能暴露错误的测试

下面是项目接口测试的写法示意:client 和 seed_tasks 需要由你自己的项目夹具提供。测试数据故意让筛选前有多条记录、筛选后超过一页:

python 复制代码
def test_filter_before_pagination(client, seed_tasks):
    # seed_tasks contains three done tasks and two todo tasks.
    response = client.get("/api/tasks?status=done&page=2&page_size=2")

    body = response.json()
    assert response.status_code == 200
    assert body["total"] == 3
    assert len(body["items"]) == 1
    assert all(item["status"] == "done" for item in body["items"])


def test_empty_filter_result(client, seed_tasks):
    response = client.get("/api/tasks?status=doing")

    body = response.json()
    assert response.status_code == 200
    assert body["total"] == 0
    assert body["items"] == []


def test_invalid_page_size_uses_existing_error(client):
    response = client.get("/api/tasks?page_size=101")

    assert response.status_code == 400
    assert response.json()["code"] == "INVALID_PARAMETER"

如果实现错误地先取两条任务再筛选,第二页的 total、数据数量或状态断言会失败。测试数据必须让错误路径和正确路径产生不同结果,否则测试无法区分两种实现。

3. 先运行失败基线

把实现临时改成一个明显错误的版本,只用于验证测试有效性:

python 复制代码
def list_tasks(*, tenant_id, query):
    # Deliberately wrong: filtering happens after the page is selected.
    page = repository.find_page(
        tenant_id=tenant_id,
        status=None,
        offset=(query.page - 1) * query.page_size,
        limit=query.page_size,
    )
    page.items = [item for item in page.items if item.status == query.status]
    return page

运行聚焦测试后,预期至少有一个断言失败。完成失败基线验证后立即恢复或修改到正式实现,不要把故意错误代码留在工作区。实际项目里更稳妥的方式是使用临时分支或保存补丁,避免误提交。

如果暂时没有 API 测试环境,先用下面完全独立、只依赖 Python 标准库的脚本验证"先分页后筛选"确实能被测试区分。将代码保存为 test_filter_demo.py,运行 python test_filter_demo.py:

python 复制代码
import io
import unittest


ROWS = [
    {"id": 1, "tenant": "A", "status": "todo"},
    {"id": 2, "tenant": "A", "status": "done"},
    {"id": 3, "tenant": "A", "status": "done"},
    {"id": 4, "tenant": "A", "status": "todo"},
    {"id": 5, "tenant": "A", "status": "done"},
    {"id": 6, "tenant": "B", "status": "done"},
]


def wrong_page(rows, tenant, status, page, size):
    own = [row for row in rows if row["tenant"] == tenant]
    offset = (page - 1) * size
    # 错误版本:先分页,再筛选;total 也是筛选前的值。
    items = [row for row in own[offset:offset + size]
             if row["status"] == status]
    return {"total": len(own), "items": items}


def correct_page(rows, tenant, status, page, size):
    matched = [row for row in rows
               if row["tenant"] == tenant and row["status"] == status]
    offset = (page - 1) * size
    return {"total": len(matched), "items": matched[offset:offset + size]}


def check(implementation):
    class FilterTests(unittest.TestCase):
        def test_second_page(self):
            result = implementation(ROWS, "A", "done", 2, 2)
            self.assertEqual(result["total"], 3)
            self.assertEqual([row["id"] for row in result["items"]], [5])

        def test_empty_result(self):
            result = implementation(ROWS, "A", "doing", 1, 2)
            self.assertEqual(result, {"total": 0, "items": []})

        def test_tenant_isolation(self):
            result = implementation(ROWS, "B", "done", 1, 2)
            self.assertEqual(result["total"], 1)
            self.assertEqual(result["items"][0]["id"], 6)

    return unittest.TextTestRunner(stream=io.StringIO()).run(
        unittest.defaultTestLoader.loadTestsFromTestCase(FilterTests)
    )


red = check(wrong_page)
green = check(correct_page)
assert len(red.failures) == 2 and not red.errors
assert green.wasSuccessful() and green.testsRun == 3
print("RED: 2 failed, 1 passed; GREEN: 3 passed")

这段演示用内存列表模拟查询,不代表数据库查询或 HTTP 接口已经验证;真实仓库仍需用其测试夹具和运行命令覆盖入口、SQL 和权限。

在真实项目中,失败基线与修复结果也要明确记录,不能直接照抄演示输出:

text 复制代码
命令:<项目实际运行的聚焦测试命令>
失败基线:<修复前实际的 failed / passed / skipped 数量与关键断言>
修复后结果:<修复后实际的通过数量与退出状态>
结论:<测试是否真的暴露"先分页后筛选"的错误>

4. 修改实现并分层运行

恢复正确实现后,按由近及远的顺序执行:

text 复制代码
1. python -m pytest tests/unit/test_task_query.py -q
2. python -m pytest tests/api/test_tasks.py -q
3. python -m pytest
4. ruff check .
5. git diff --check

如果项目使用 Maven、Gradle、Go 或其他工具,替换成仓库现有命令。不要为了文章示例强行引入 pytest 或 ruff。聚焦测试失败时,先修复当前契约;完整测试失败时,判断是否出现旧功能回归。

5. 让 AI 审查测试本身

测试转绿后,再给助手一个只读审查任务:

text 复制代码
请只读审查本次新增测试,不修改代码。
逐项回答:
1. 每个测试保护哪个需求;
2. 如果实现先分页后筛选,哪些测试会失败;
3. 断言是否检查真实响应字段,而不是只检查状态码;
4. 是否存在只在 mock 中成立、真实入口不会经过的测试;
5. 是否遗漏空结果、非法参数、权限、租户和回归场景;
6. 哪些测试因外部服务或本地环境没有实际执行。

这一步可以发现"测试写了很多、但没有覆盖风险"的问题,也能让测试与需求矩阵保持可追溯。

五、常见问题与实践建议

1. 测试只断言状态码

状态码只能说明请求进入了某种响应分支。继续断言响应字段、数据数量、排序、错误码、敏感字段缺失和数据库状态。

2. Mock 替代了所有真实依赖

Mock 适合隔离纯逻辑,但不能证明 SQL、序列化、事务或依赖注入正确。关键路径至少增加一层集成或接口测试。

3. 把测试实现细节写死

如果需求只要求结果按创建时间倒序,就不要断言必须调用某个内部私有方法。过度绑定实现会阻碍安全重构;只有性能、缓存命中或审计等明确要求才锁定内部行为。

4. 测试失败就直接改测试

先比较需求、现有契约和实现。如果实现错了,修实现;如果需求发生了有意变化,更新契约和测试并记录变化;如果环境缺少数据库,修复环境或标记未验证,不能删掉断言。

5. 忽略跳过和警告

测试报告中要区分 passed、failed、skipped、xfail、warning 和未执行。跳过的权限测试不能被总结为"全部测试通过"。

6. 测试数据不具备区分度

增加能区分边界的样本:空列表、单条记录、跨页记录、重复值、不同租户、无权限用户和非法输入。测试数据越接近"所有路径都得到同一个结果",测试价值越低。

六、进阶思考

可以把测试有效性进一步量化。变异测试会故意改变比较符号、删除条件或修改返回值,然后检查测试是否失败;如果大量错误变更仍能通过,说明测试覆盖存在空洞。AI 可以帮助生成变异候选和分析漏测点,但变异结果仍需人工确认,不能把工具分数当成业务正确率。

测试还应覆盖可观测性。对于重试、超时、降级和异步任务,除了最终结果,还要检查重试次数、幂等键、日志字段、指标和错误链路,避免功能表面成功但系统处于不可控状态。

把测试命令、夹具说明和环境依赖写进项目指令文件,可以减少 AI 每次重新猜测运行方式。把验收矩阵保留在 Issue 或 PR 中,则能让后续审查知道每条测试为什么存在。

结论

让 AI 编写测试时,先从需求和场景矩阵出发,再建立能暴露缺陷的失败基线;实现修改后按聚焦测试、完整回归、静态检查和 diff 审查逐层推进。测试通过必须伴随实际命令、执行范围和未验证项,才能成为可信证据。

下一篇将处理测试或生产日志中的真实失败:先收集完整上下文,再让 AI 缩小复现范围、提出根因假设并用回归测试验证修复。

参考资料

相关推荐
qinglongcn1 小时前
Agent 可能出什么事——五类异常与应对
人工智能·编辑器·ai编程
小易老师1 小时前
用中间件把任务规划嵌入 Agent 循环:告别把计划写死在 Prompt 里
人工智能·agent
Ai-_Man1 小时前
Coze扣子还是不可以导出左侧所有对话,只能点进去单条内容导出,方法不对吗?
开发语言·人工智能·ai·小程序·1024程序员节
机器学习之心1 小时前
基于贝叶斯物理信息神经网络的矿产预测与不确定性量化,MATLAB代码及复现报告
人工智能·神经网络·matlab·贝叶斯物理信息神经网络
weixin_449229381 小时前
Skill应用11:详细设计自动生成|强制“文字先行”,杜绝AI只堆代码不讲设计
人工智能·agent·架构设计·详细设计·trae·skill应用
AiNightVision1 小时前
电力巡检夜视方案选型指南——无人机/机器人/固定监测三种场景怎么选
人工智能·科技·车载系统·机器人·无人机·边缘计算·视频
涛声依旧-底层原理研究所2 小时前
拆解 AI Agent 的四个内核:Loop、Tools、Memory 与 Harness
人工智能·架构
D e re k ZH2 小时前
保序回归:从 PAVA 到可并堆的单调约束优化
c++·算法·数据挖掘·回归·kotlin
田里的水稻2 小时前
FA_CNN卷积神经网络十八---感知机到多层感知机PyTorch
人工智能·pytorch·python