上篇说完测试策略,我就翻车了
写测试策略那篇的时候,我提到B档让AI写测试骨架、人审断言,翻了一次车。
就是这次。
一个看起来不能再简单的函数------格式化价格,把分转成元,加个货币符号。AI写完了代码,自己写了测试,跑了全绿,我扫了一眼覆盖率100%,点了merge。上线第一天,用户看到一个空白页。
排查下来,问题出在AI写的测试上------或者说,出在我对"AI写测试"这件事的认知上。
现象
线上报错信息:
java
TypeError: Cannot read properties of null (reading 'toFixed')
at formatPrice (utils/formatPrice.ts:4:25)
at OrderSummary (components/OrderSummary.tsx:42:12)
formatPrice 函数长这样:
typescript
// utils/formatPrice.ts
export function formatPrice(cents: number): string {
return `¥${(cents / 100).toFixed(2)}`
}
调用方是订单摘要组件,从API获取订单数据后逐项渲染价格。某个历史订单的 price 字段在某些条件下是 null------API的设计问题,但前端没有做防御。
排查
我第一反应是"测试覆盖率没跑满",但打开测试文件一看,覆盖率100%,10条测试用例全部通过。
typescript
// AI生成的测试 --- 10条全部通过
import { formatPrice } from '../formatPrice'
describe('formatPrice', () => {
it('formats 1299 cents to ¥12.99', () => {
expect(formatPrice(1299)).toBe('¥12.99')
})
it('formats 0 cents to ¥0.00', () => {
expect(formatPrice(0)).toBe('¥0.00')
})
it('formats 99 cents to ¥0.99', () => {
expect(formatPrice(99)).toBe('¥0.99')
})
it('formats 10000 cents to ¥100.00', () => {
expect(formatPrice(10000)).toBe('¥100.00')
})
// ... 还有6条类似的,全是 "正常整数 → 正常字符串"
})
text
// 运行结果
PASS utils/__tests__/formatPrice.test.ts
✓ formats 1299 cents to ¥12.99 (2ms)
✓ formats 0 cents to ¥0.00 (1ms)
✓ formats 99 cents to ¥0.99 (1ms)
✓ formats 10000 cents to ¥100.00 (1ms)
✓ ... 6 more
Tests: 10 passed, 10 total
Coverage: 100%
所有测试都正常。但仔细看------10条测试用例,全部是"正常输入→正常输出"。没有一条测试 null、undefined、负数、浮点数、超大数的场景。
根因
AI为什么没写这些边界测试?不是它"不会",是它看不到。
AI生成测试用例时,输入来源只有两个:函数签名和JSDoc。
php
函数签名: formatPrice(cents: number): string
JSDoc: 无(或者简单的 "格式化价格")
基于这两个信息,AI推导出的结论是:cents 是一个 number,所以测试用例应该用 number 类型的数据。它不知道这个 number 可能来自一个不可靠的API,不知道API在某些条件下返回 null,不知道调用方有没有做防御性检查。
AI的测试边界等于它的信息边界。 它看不到函数之外的调用上下文,所以它生成的测试只覆盖了"函数自己能看到的"范围。
这个问题的本质,跟第二弹里AI重构删"死代码"的根因一样------AI的静态分析只能看到代码本身,看不到代码被怎么用。
修复
修复分三步:
第一步:补测试。 把边界情况和异常输入加进去。
typescript
// 补的测试
describe('formatPrice --- edge cases', () => {
it('handles null gracefully', () => {
// @ts-expect-error --- 测试运行时的实际行为
expect(() => formatPrice(null)).not.toThrow()
})
it('handles undefined gracefully', () => {
// @ts-expect-error
expect(() => formatPrice(undefined)).not.toThrow()
})
it('handles negative numbers', () => {
expect(formatPrice(-100)).toBe('¥-1.00')
})
it('handles large numbers without overflow', () => {
expect(formatPrice(99999999)).toBe('¥999999.99')
})
})
text
// 运行结果
PASS utils/__tests__/formatPrice.test.ts
✓ handles null gracefully (2ms)
✓ handles undefined gracefully (1ms)
✓ handles negative numbers (1ms)
✓ handles large numbers without overflow (2ms)
Tests: 4 passed, 4 total
第二步:加类型守卫。 不改调用方,在函数入口做防御。
typescript
export function formatPrice(cents: number | null | undefined): string {
if (cents == null) {
return '¥0.00'
}
return `¥${(cents / 100).toFixed(2)}`
}
第三步:沉淀检查清单。 以后AI写测试,人审的时候照着这个清单过一遍,比凭经验靠谱。本文测试环境基于 Jest 29.7 + TypeScript 5.5,strictNullChecks 已开启。
javascript
AI写测试人审检查清单:
□ 有没有覆盖 null/undefined 输入?
□ 有没有覆盖空数组/空对象?
□ 有没有覆盖边界值(0、负数、极大/极小)?
□ 有没有覆盖 API 超时/报错的场景?
□ 断言是只写了"等于什么",还是也写了"不做什么"?
人 vs AI 写测试的思维差异
这次翻车让我重新想了一件事:人写测试和AI写测试,到底差在哪?
| 维度 | 人写测试 | AI写测试 |
|---|---|---|
| 输入来源 | 调用方的实际使用场景 + 业务知识 | 函数签名 + JSDoc + 代码实现 |
| 测试思维 | 防御性("别人会怎么坑我") | 乐观性("别人会按文档用") |
| 边界覆盖 | 基于经验("这个参数线上传过null") | 基于类型("类型是number,传number就行") |
| 异常断言 | 会写"不应该走到这里"的断言 | 只写"走到这里应该返回什么" |
人的测试思维是防御性的 ------写测试的时候会想"调用方可能怎么误用这个函数","线上实际跑的时候会产生什么垃圾数据"。AI的测试思维是乐观性的------基于"这个函数按设计应该怎么用"来生成测试,不会主动考虑异常情况。
这不是AI的缺陷,这是它的信息边界决定的。人知道"这个API之前返回过null",AI不知道。人知道"这个函数被三个地方调用,其中一个调用方传参不规范",AI不知道。AI写测试的质量,取决于你给了它多少上下文。
边界:AI写测试什么场景好用,什么场景不好用
经过这次翻车,我后来定了几个规则:
AI写测试好用的场景:
- 纯函数/工具类,输入输出明确
- 数据转换(JSON parse/stringify、格式转换)
- 类型定义和校验函数
- 脚手架代码的初始化测试
AI写测试不好用的场景:
- 业务逻辑复杂、分支多的代码
- 依赖外部状态(数据库、文件系统、第三方API)
- 多步操作(需验证"某一步执行了,某一步没执行")
- 涉及异步时序的代码
对于"不好用"的场景,让AI写测试骨架,人补断言和边界。对于"好用"的场景,也不是完全信任------人审的时候至少过一遍检查清单。
一个format函数教会我的事
回头来看,这个翻车的问题不在AI,在我。
我以为"让AI写测试"是"让AI帮我把测试写了"------节省时间,提高效率。但实际不是。"让AI写测试"的正确姿势是"让AI起草测试,人来做测试该做的事"------判断什么值得测,什么不值得测,什么场景下会出问题。
AI写测试最大的价值不是"替代你写测试",是"用最快的速度覆盖正常路径,把你解放出来去思考边界路径"。这个认知不对,翻车就是迟早的事。