摘要
让 AI 编程助手"补几个测试"并不等于获得了可靠验证。测试可能只覆盖最容易通过的成功路径,断言可能没有检查真实结果,甚至可能因为测试本身写错而让错误实现变绿。
把测试任务交给 AI 时,应先确定需求场景和失败基线,再让助手编写能暴露缺陷的测试;修改实现后,按聚焦测试、回归测试、静态检查和差异审查逐层验证。测试通过只是证据链中的一环,还要确认测试确实执行、断言确实有效、环境限制确实被记录。
一、背景与问题
代码变更最容易被"看起来能运行"误导。一个函数返回了列表,测试只断言状态码为 200;一个分页接口返回了数据,测试只断言列表非空;一个权限接口没有泄露内容,测试却没有使用不同用户身份。这些测试数量不少,却不能证明需求成立。
AI 生成测试时还会出现另一类问题:它根据实现反推测试,而不是根据需求定义行为。实现里把默认页大小写成 20,测试就把 20 固定下来;实现错误地先分页再筛选,测试数据只有一页,错误自然不会暴露。
可靠测试需要回答三个问题:
- 如果当前实现有缺陷,测试是否会失败?
- 测试断言检查的是用户可观察行为,还是内部实现细节?
- 测试失败后,能否判断是代码、测试、环境还是需求契约出了问题?
二、核心概念
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 缩小复现范围、提出根因假设并用回归测试验证修复。