测试全绿,上线还是炸了
利益声明:本文作者参与了 wescode 的开发。文中涉及 wescode 的技术描述基于内部测试数据;涉及其他工具的描述基于各家公开文档和社区反馈。
测试全绿。CI 一路通过。Code Review 两个同事都批了 Approve。
合进主干,部署上线。
两小时后客服群炸了:"用户列表排序不对了。"
你打开代码一看——AI 帮你优化了一个排序函数,把 localeCompare 换成了数字比较。性能确实好了。但排序结果从按名字变成了按 ID。
测试为什么没抓住?因为测试断言的是"返回的都是 active 用户",没有断言排序顺序。
编译器为什么没抓住?因为类型没变,输入输出都是 User[]。
Review 为什么没抓住?因为 reviewer 看到 sort 函数变简洁了,觉得是个好改动——排序算法不是 review 的重点。
所有安全网都没拦住它。不是因为网不够密,而是网的种类不对。
测试验证的是什么
测试验证的是你提前决定要验证的东西。
你决定测"返回值是否只包含 active 用户"→ 写断言 → 断言通过。
你没决定测"返回值的排序顺序"→ 没写断言 → 排序变了也不报错。
这不是覆盖率的问题。你可以有 100% 的行覆盖率,但如果你的断言只检查"函数跑完没报错",那 100% 覆盖率等于 0% 行为覆盖率。
看一个真实的例子:
// user-service.ts
function getActiveUsers(users: User[]): User[] {
return users
.filter(u => u.status === 'active')
.sort((a, b) => a.name.localeCompare(b.name))
}
// user-service.test.ts — 100% 行覆盖率
test('returns only active users', () => {
const users = [
{ id: 1, name: 'Alice', status: 'active' },
{ id: 2, name: 'Bob', status: 'inactive' },
{ id: 3, name: 'Charlie', status: 'active' },
]
const result = getActiveUsers(users)
expect(result).toHaveLength(2) // ✅ 断言了数量
expect(result.every(u => u.status === 'active')) // ✅ 断言了内容
.toBe(true)
// [未断言] 没断言排序——覆盖率 100%,但排序行为没被验证
})
AI 把 .sort((a, b) => a.name.localeCompare(b.name)) 改成 .sort((a, b) => a.id - b.id)。编译通过、测试通过——因为返回值仍然只有 active 用户,数量也对。但前端页面上用户列表从按名字排序变成了按 ID 排序。
覆盖率衡量的是"代码被执行了",不是"行为被验证了"。
AI 改代码时哪些行为容易变
测试通常不断言、但一变就出事的行为,比你想的多。
排序顺序
上面已经看到了。这是最经典的一种。
深拷贝 vs 引用
// 改之前:返回新对象
function getConfig(): AppConfig {
return { ...defaultConfig, timestamp: Date.now() }
}
// AI "优化"后:返回引用
function getConfig(): AppConfig {
defaultConfig.timestamp = Date.now()
return defaultConfig // 返回了同一个对象!
}
类型完全一样。测试通常只 toEqual 比较值不比较引用。但如果下游代码这样用:
const config1 = getConfig()
config1.timeout = 5000 // 以为只改了 config1
const config2 = getConfig() // 实际上 config2.timeout 也是 5000
你改的是 config1,但 config2 也被影响了——因为它们是同一个对象。
错误类型变了
// 改之前
function getUser(id: number): User {
if (id < 0) throw new NotFoundException('User not found')
return db.findUser(id)
}
// AI 改之后——返回 null 代替抛异常
function getUser(id: number): User | null {
if (id < 0) return null
return db.findUser(id)
}
上游有一个 middleware 专门 catch NotFoundException 做分类处理:
try {
const user = getUser(id)
} catch (err) {
if (err instanceof NotFoundException) {
return res.status(404).json({ code: 'NOT_FOUND' })
}
return res.status(500).json({ code: 'INTERNAL_ERROR' })
}
改成返回 null 之后,这个 catch 块永远不会执行——程序走到下面试图读 null.name 时直接 500。从"友好的 404"变成了"莫名的 500"。
异步执行顺序
// 改之前:顺序执行
async function syncData() {
await writeToDatabase(data)
await updateSearchIndex(data)
await invalidateCache(data.id)
}
// AI "优化"后:并行执行
async function syncData() {
await Promise.all([
writeToDatabase(data),
updateSearchIndex(data),
invalidateCache(data.id),
])
}
性能更好了。但 updateSearchIndex 依赖 writeToDatabase 先完成——并行执行可能导致搜索索引读到旧数据。单测是串行的,这个 bug 在测试里完全看不到。
这些都是"行为"——代码对外部世界的可观测效果。类型没变、测试没挂,但行为已经不同了。
当前的验证体系停在了哪一层
大多数工具和 CI 流程的验证能力分布在前三层:
| 层级 | 验证什么 | 谁在做 | 能抓住什么 |
|---|---|---|---|
| L0 | 编译通过 | 所有工具 | 语法错误、类型不匹配 |
| L1 | 测试通过 | 所有工具 | 你写了断言的那些行为 |
| L2 | lint + 静态分析 | ESLint / tsc --strict | 未使用变量、简单的类型问题 |
| L2.5 | 行为是否改变 | ? | 你没想到要断言的行为变化 |
前三层所有主流 AI 编程工具都做了(或者说依赖 CI 做了)。L2.5 这一层——"改完之后行为和之前一样吗"——目前几乎没有工具覆盖。
这层的难度在于:它需要在改动之前先"拍一张快照"记录函数的可观测行为,改完之后再拍一张,自动对比差异。手动做不现实——你总不能每次改代码前先把"所有函数在所有输入下的输出"跑一遍。
wescode 的 L2.5 行为基线:怎么做到的

wescode 在 AI 改代码时自动做三件事:
第一步:改之前,记录涉及函数的可观测行为
不是跑所有函数——那不现实。而是只对这次改动涉及的函数(CKG 调用图精确定位,不靠 grep 猜),在已有测试的输入下记录输出:
- 返回值的结构和内容(包括字段顺序、嵌套结构)
- 返回值的排序顺序
- 抛出的异常类型和异常消息
- 返回值是新对象还是同一个引用
- 副作用(写文件、发请求、写数据库)的调用签名
第二步:改之后,重新执行相同输入,记录输出
用完全一样的测试输入跑一遍。这步不需要你写新测试——它复用已有测试的输入数据。
第三步:自动对比两次输出的差异
如果 getActiveUsers() 改之前返回 [{name:"Alice"}, {name:"Bob"}],改之后返回 [{name:"Bob"}, {name:"Alice"}]——L2.5 标出来:排序顺序变了。
如果 getUser(-1) 改之前抛 NotFoundException,改之后返回 null——L2.5 标出来:错误处理方式变了。
如果 getConfig() 改之前每次返回新对象,改之后返回同一个引用——L2.5 标出来:返回值从深拷贝变成了引用。
然后它问你:这是你有意改的,还是无意引入的?
走一遍完整流程

开头的场景——AI 把排序函数从 localeCompare 换成数字比较。
没有 L2.5:
1. AI 改完代码
2. 编译通过 ✅
3. 测试通过 ✅(没有排序断言)
4. Review 通过 ✅(看到代码简化了)
5. 合进主干
6. 三天后 QA 报 bug:排序变了
有 L2.5:
1. AI 改完代码
2. 编译通过 ✅
3. 测试通过 ✅
4. L2.5 标出:getActiveUsers() 返回值排序从 name ASC 变为 id ASC ⚠️
5. 你看到标注,决定:这不是我想要的改动
6. 回退排序逻辑,保留其他优化
7. 没有 bug 进入主干
区别在第 4 步——L2.5 在 merge 之前就发现了行为变化。你不需要提前想到要测排序,因为 L2.5 自动对比了。
三层安全网联动

L2.5 不是孤立的。它和 CKG(代码知识图谱)、CSE(约束满足引擎)一起构成三层安全网:
| 层 | 问题 | 回答 |
|---|---|---|
| CKG | 改了这里还影响哪里 | 完整的调用方清单(含接口间接调用) |
| CSE | 有没有破坏项目规矩 | 13 个 Checker 自动标注违反统计惯例的代码 |
| L2.5 | 行为和改之前一样吗 | 改动前后自动对比排序、返回值结构、错误类型 |
当你让 AI 修改一个核心函数时:
| 步骤 | Cursor / Claude Code | wescode |
|---|---|---|
| AI 修改目标函数 | ✅ | ✅ |
| 发现并更新所有调用方 | ⚠️ 可能遗漏(grep/Embedding) | 支持,CKG 精确追踪 |
| 检查是否违反项目惯例 | 不支持 | 支持,CSE 13 Checker |
| 验证行为是否一致 | 不支持 | 支持,L2.5 基线对比 |
各工具在"行为验证"维度的对比
| 工具 | 编译检查 | 测试运行 | 静态分析 | 行为基线对比 |
|---|---|---|---|---|
| Cursor | ✅ 依赖 CI | ✅ 依赖 CI | ✅ 依赖 CI | 不支持 |
| Claude Code | ✅ 自动跑 | ✅ 自动跑 | ⚠️ 部分 | 不支持 |
| GitHub Copilot | ✅ 依赖 CI | ✅ 依赖 CI | ✅ 依赖 CI | 不支持 |
| wescode | ✅ | ✅ | ✅ CSE | 支持,L2.5 |
目前市面上的 AI 编程工具在 L0-L2 做得都不错(或者说依赖 CI)。L2.5 这一层是 wescode 的差异化能力。
L2.5 能检测什么,不能检测什么
L2.5 不是万能的。它覆盖的是单函数级别的可观测行为变化。
| 能检测 | 不能检测 |
|---|---|
| 返回值的排序顺序变了 | 并发下的执行时序变了(需要并发测试) |
| 错误类型从 Exception 变成 null | 分布式系统的跨服务副作用(需要集成测试) |
| API 响应少了一个字段 | 性能从 200ms 变成 2s(不在行为快照范围内) |
| 返回值从深拷贝变成引用 | 视觉渲染的差异(那是 E2E 截图对比的事) |
| 函数的副作用调用签名变了 | 内存泄漏(需要 profiling 工具) |
| 输出格式(JSON 字段顺序)变了 | 安全漏洞(需要安全扫描工具) |
L2.5 精确覆盖的是"测试全绿但上线出 bug"最常见的那一类原因——函数级别的行为回归。并发、分布式、性能、视觉——这些有各自的验证手段,L2.5 不试图替代它们。
另一个限制:L2.5 依赖已有测试的输入数据来生成快照。如果一个函数完全没有测试,L2.5 没有输入可以跑,也就没有快照可以对比。它能帮你发现"写了测试但断言不够"的情况,不能帮你发现"根本没写测试"的情况。
你现在能做什么
不管用什么工具,这几个习惯能降低"测试全绿但行为变了"的风险:
1. 排序相关的函数,断言里加顺序
// 不够
expect(getActiveUsers(users)).toHaveLength(3)
// 够
expect(getActiveUsers(users).map(u => u.id)).toEqual([1, 3, 5])
2. 返回值被下游 catch 分流的,断言错误类型
// 不够
expect(() => getUser(-1)).toThrow()
// 够
expect(() => getUser(-1)).toThrow(NotFoundException)
3. 返回值会被多处引用的,断言引用独立性
// 验证每次调用返回的是新对象
const config1 = getConfig()
const config2 = getConfig()
expect(config1).not.toBe(config2) // 不是同一个引用
expect(config1).toEqual(config2) // 但值相等
4. 改完之后不只跑单元测试——跑一遍 E2E
单元测试每个函数独立跑,看不到跨模块的行为变化。E2E 测试从用户视角走完整流程,能抓住"我点了按钮之后列表排序变了"这种单元测试抓不住的问题。
这四条不解决根本问题——你仍然只能覆盖你想到的行为。如果你想让工具自动帮你做"改之前拍快照 → 改之后对比"这件事——wescode 的 L2.5 就是干这个的。