测试全绿,上线还是炸了

wescode · 2026-10-08 · 测试 / 行为验证 / 痛点

利益声明:本文作者参与了 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测试通过所有工具你写了断言的那些行为
L2lint + 静态分析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 Codewescode
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 就是干这个的。