返回博客

25 个单元测试全绿,上线 5 分钟后用户报 bug

重构完成,25 个单元测试全部通过,PR 合入,部署上线。5 分钟后用户反馈某个级联字段组件完全不工作——子字段全部置灰,无法操作。

回头检查,每一个 helper 函数都有完整测试覆盖,逻辑没有任何问题。那 bug 在哪?

现象

一个级联字段组件(类似省市区联动),从一个复杂的表单页中提取出来,拆解为若干纯函数:

detectCascadeGroup(columns)         // 检测哪些字段构成级联组
buildCascadeChildrenMap(fields)     // 构建父子字段映射
collectSameLevelSiblings(key, fields) // 收集同级兄弟
computeSiblingPatch(...)            // 计算级联更新

每个函数都有独立的单元测试,25 个 it() 全绿。重构前后行为一致,信心满满合并。

结果:用户打开页面,子字段全部 disabled,报 undefined

根因

问题不在任何一个函数体内,而在父组件如何拼装 props

父页面按「分组」渲染表单时,把当前 section 的字段切片后传给组件:

// 父页面(有问题的写法)
sections.map(section => (
  <CascadeFieldGroup
    fields={sectionItems}    // ← 只有本 section 的字段!
    values={sectionValues}   // ← 只有本 section 的 values!
  />
))

CascadeFieldGroup 的设计前提是接收整个级联组的全量字段。当某个子字段要查找它的父字段时,父字段在另一个 section,根本不在传入的 fields 里——于是读到 undefined,子字段降级为 disabled。

纯函数测试用的是手工构造的输入,每个 test case 都老老实实传入了完整数据。没有一个测试模拟了「调用方只传子集」这一行为——而这恰恰是生产环境真正发生的事。

坑:纯函数全覆盖 ≠ 集成契约被验证

单元测试验证「函数在正确输入下的行为」,但它无法验证「调用方是否传入了正确的输入」。当被测函数接收一个集合(数组/Map),而调用方有可能对这个集合做过滤、切片、按 section 分组时——你需要一类新的测试,专门针对「传错数据形状」的场景。

正解

在单元测试之外,添加一类装配契约测试(Assembly Contract Test),明确固定「调用方不能传子集」这一隐性要求:

describe('CascadeGroupProvider 装配契约', () => {
  it('传入完整字段时,子字段可以读到父字段的值', () => {
    const fullFields = [parentField, childField]
    const fullValues = { titleSeries: '工程技术', reviewCategory: '' }
    const handle = resolveHandleFor('reviewCategory', fullFields, fullValues)
    expect(handle?.disabled).toBe(false)
  })
 
  it('传入子集时,子字段因找不到父而被 disabled(这是预期降级,同时也是红灯哨兵)', () => {
    const subsetFields = [childField]  // 故意漏掉 parentField
    const fullValues = { titleSeries: '工程技术', reviewCategory: '' }
    const handle = resolveHandleFor('reviewCategory', subsetFields, fullValues)
    expect(handle?.disabled).toBe(true)  // 测试通过 = 降级行为被固定
  })
})

第二个测试是关键:它描述的是「传错了会发生什么」。一旦未来有人在调用处切片 props,这个测试会变红,阻断合并。

正解:把装配契约提取成纯函数,让它也可测

如果 jsdom / renderHook 在当前环境跑不起来,不必强行写组件测试。把「Provider 内部的核心判断逻辑」提取为纯函数(如 resolveHandleFor),再对这个纯函数写契约测试。既规避了测试环境限制,又真实固定了调用方的数据形状要求。

决策规则

不是所有函数都需要契约测试,按以下规则判断:

  • 输入是简单标量(number / string / 固定形状的对象)→ 单元测试够了
  • 输入是集合(数组 / Map),调用方有可能对其过滤/切片 → 必须加契约测试
  • 通过 React Context / render-prop / slot 消费 → 装配是隐式的,必须加契约测试

一句话外卖

覆盖率是度量;契约是设计产物。100% 行覆盖可以和一个集成 bug 同时存在——缺失的那个测试,是关于调用方应该传什么形状的数据