TDD 新范式:测试用例定规范,AI 打工,人类掌舵

11 阅读13分钟

在 AI 辅助编程日益普及的今天,一个关键问题浮现:在 AI 生成代码的流程中,有什么比"写代码"本身更重要? 本文通过"后选标红"会议室冲突检测功能的实现过程,展示测试用例如何成为 AI 代码与人类代码的共同"锚点"。


一、写在前面:AI 写代码的时代,我们还需要什么?

过去一年,AI 编码能力突飞猛进,各家大模型可以在几秒内生成数百行可运行的代码。然而,一个经常被忽视的事实是:AI 生成的代码"看起来对"和"实际对"之间,存在巨大鸿沟。

这个鸿沟怎么填补?答案不是"写更多代码",而是写更好的测试

当测试用例足够完整时,谁来写实现代码已经不重要了。测试用例才是真正的"需求规范"。


二、业务需求:"后选标红"是什么?

在会议室预约系统中,当多个会议时间冲突时,有两种标红策略:

为什么会有"后选标红"? 因为用户体验更友好:用户先安排好的会议不应因为后面添加的会议而突然变红,只有新操作才触发冲突提示。

核心规则

新会议与已有会议冲突 → 只标红新会议。移除会议后,剩余会议重新评估,保证每个冲突组至少有一个正常。

选定的具体场景:三个会议交叉重叠

会议开始时间结束时间
Meeting 108:0009:00
Meeting 208:3009:30
Meeting 309:0010:00

时间关系:1-2 重叠,2-3 重叠,1-3 不重叠。

Meeting 1  ████████░░░░░░░░░░░░
Meeting 2  ░░░░████████░░░░░░░░
Meeting 3  ░░░░░░░░░████████████
           08:00    09:00    10:00

场景一:按 1→2→3 顺序添加

用例操作预期
1A 2A 3A => 2x 3x全部放 A1 正常,2、3 冲突
-> 1 null => 2x移出 12 冲突,1、3 正常
-> 2 null =>移出 2全部正常
-> 3 null => 2x移出 32 冲突,1、3 正常
-> 1 B => 2x1 移到 B2 冲突,1、3 正常
-> 2 B =>2 移到 B全部正常
-> 3 B => 2x3 移到 B2 冲突,1、3 正常

场景二:按 2→1→3 顺序添加

用例操作预期
2A 1A 3A => 1x 3x全部放 A2 正常,1、3 冲突
-> 1 null => 3x移出 11、2 正常,3 冲突
-> 2 null =>移出 2全部正常
-> 3 null => 1x移出 31 冲突,2、3 正常
-> 1 B => 3x1 移到 B1、2 正常,3 冲突
-> 2 B =>2 移到 B全部正常
-> 3 B => 1x3 移到 B1 冲突,2、3 正常

三、补齐测试用例

在写任何实现代码之前——无论是我们自己写,还是让 AI 生成——先把测试用例变成可执行的代码

具体如何补齐测试用例,请参考上一篇文章 TDD实战-会议室冲突检测的红绿重构循环


四、AI 实现:让大模型生成代码

现在,将同一套测试用例作为"需求规范"输入给 AI。以下是 AI 生成的会议室移除时的核心逻辑:

export default class LastConflictManager {
    removeMeetingFromRoom(meeting, roomId) {
        const roomMeetings = this.getRoomMeetings(meeting.date, roomId)
        const index = roomMeetings.findIndex(m => m.id === meeting.id)
        if (index === -1) return
        roomMeetings.splice(index, 1)
        roomMeetings.forEach(m => { m.isConflict = false })

        // Stage 1: 左侧反向扫描
        for (let i = index - 1; i >= 0; i--) {
            for (let j = i - 1; j >= 0; j--) {
                if (this.hasConflict(roomMeetings[i], roomMeetings[j]))
                    roomMeetings[i].isConflict = true
            }
        }

        // Stage 2: 右侧扫描(区分有序/无序)
        const isTimeOrdered = (() => {
            for (let k = index + 1; k < roomMeetings.length; k++)
                if (dayjs(roomMeetings[k-1].start).isAfter(dayjs(roomMeetings[k].start)))
                    return false
            return true
        })()

        if (isTimeOrdered) {
            for (let i = index; i < roomMeetings.length; i++)
                for (let j = i + 1; j < roomMeetings.length; j++)
                    if (this.hasConflict(roomMeetings[i], roomMeetings[j]))
                        roomMeetings[i].isConflict = true
        } else {
            for (let i = roomMeetings.length - 1; i >= index; i--)
                for (let j = i - 1; j >= index; j--)
                    if (this.hasConflict(roomMeetings[i], roomMeetings[j]))
                        roomMeetings[i].isConflict = true
        }

        // Stage 3: 跨侧冲突,标记右侧
        for (let i = 0; i < index; i++)
            for (let j = index; j < roomMeetings.length; j++)
                if (this.hasConflict(roomMeetings[i], roomMeetings[j]))
                    roomMeetings[j].isConflict = true
    }
}

removeMeetingFromRoom 方法的实现非常复杂,包含三个扫描阶段:

  • 左侧反向扫描:从断点向前遍历,冲突时标记当前会议(即更靠近断点的)。
  • 右侧分叉:先判断剩余会议是否时间有序,然后选择正向或反向扫描。
  • 跨侧冲突:左侧与右侧比较时,标记右侧会议。

逻辑复杂,难以直接判断是否正确。测试用例会替我们验证。


五、人类实现:用"冲突分组"让逻辑更清晰

人类手写的实现如下:

class LastConflictManager {
  // 存储策略:新会议插入数组头部
  addMeetingToRoom(meeting, roomId) {
    const { date } = meeting
    const roomMeetings = this.getRoomMeetings(date, roomId)
    const hasConflictFlag = roomMeetings.some(m => this.hasConflict(m, meeting))
    meeting.isConflict = hasConflictFlag
    roomMeetings.unshift(meeting)
  }

  // 分组 + 翻绿策略
  removeMeetingFromRoom(meeting, roomId) {
    const { date } = meeting
    const roomMeetings = this.getRoomMeetings(date, roomId)
    const index = roomMeetings.findIndex(m => m.id === meeting.id)
    if (index !== -1) roomMeetings.splice(index, 1)
    this.resolveConflicts(roomMeetings)
  }

  private resolveConflicts(roomMeetings: Meeting[]): void {
    const groups = this.groupMeetings(roomMeetings)
    groups.forEach(group => {
      if (group.meetings.every(m => m.isConflict)) {
        const firstMeeting = roomMeetings.find(m =>
          group.meetings.map(g => g.id).includes(m.id)
        )
        if (firstMeeting) firstMeeting.isConflict = false
      }
    })
  }

  sortMeetings(meetings) {
    return meetings.map(m => ({ ...m })).sort((a, b) => {
      if (a.start === b.start)
        return dayjs(a.end).isBefore(dayjs(b.end)) ? -1 : 1
      return dayjs(a.start).isBefore(dayjs(b.start)) ? -1 : 1
    })
  }

  groupMeetings(meetings) {
    const sorted = this.sortMeetings(meetings)
    const result = []
    let currentGroup = null

    sorted.forEach(event => {
      if (!currentGroup || dayjs(event.start).isSameOrAfter(currentGroup.end)) {
        currentGroup = {
          meetings: [event],
          end: event.end,
        }
        result.push(currentGroup)
      } else if (dayjs(event.start).isBefore(currentGroup.end)) {
        currentGroup.meetings.push(event)
        if (dayjs(event.end).isAfter(currentGroup.end)) {
          currentGroup.end = event.end
        }
      }
    })

    return result
  }
}

其中,sortMeetingsgroupMeetings 方法来自于之前的文章 如何优雅展示日历中的重叠日程?三步搞定复杂布局

人类实现的思路与 AI 完全不同:

  1. 存储策略:用 unshift(插入到数组头部),而非 push(追加到尾部)。find 从数组开头查找,找到的就是后添加的会议。
  2. 移除策略:不搞三段扫描,而是用"冲突分组"。先把会议按时间重叠程度归组,每组内部如果全部标红,就把第一个恢复为正常。

人类之所以能想得更简洁,是因为抓住了一个关键不变量:unshift 使得"添加顺序"与"数组位置"绑定,find 天然帮我们识别了"谁后选"

尽管两者采用了完全不同的数据结构和算法,它们都通过了完全相同的 72 个测试用例。

这意味着什么?如果你只看代码质量,人类写的更优。但站在业务正确性的角度,两者的产出是等价的——因为测试用例定义了"正确行为"。


六、核心结论:测试才是真正的"需求规范"

1. 测试用例 = 可执行的规格文档

72 个测试用例精确地定义了这个功能在每一个操作下的预期行为。它们比任何需求文档都更精确、更完整、更可信。

2. AI 生成代码的质量 = 测试用例的质量

反过来也成立:

测试用例写得多好,AI 生成的代码就有多好。

AI 缺乏对业务上下文的理解,只能根据输入的规则描述输出代码。如果测试用例覆盖了所有边缘情况,AI 即使生成了复杂的实现,它也至少是正确的。反之,如果测试用例有缺口,对应的错误就会悄悄溜进生产环境。

3. 测试先行比谁写代码更重要

回顾整个流程:

需求分析 → 编写测试(红)
                  ↓
         AI/人类编写代码(绿)
                  ↓
            重构优化

最关键的步骤不是"写代码",而是"写测试"。 无论是 AI 还是人类,只要测试用例覆盖得足够完整,实现代码的正确性就有保障。


七、结语

回到开头的问题:在 AI 编码时代,我们还需要什么?

我们需要更好的测试。 AI 编码降低了"写代码"的门槛,但"定义正确行为"的门槛并没有降低。72 个测试用例就像 72 个探照灯,照到哪里,代码就在哪里是安全的。

这个项目最终形成了有趣的对比:

  • AI 实现:逻辑复杂,但正确。
  • 人类实现:逻辑简洁,也正确。

两者代码风格差异很大,但都通过同一套测试,回答同一个问题:会议室冲突检测该怎么工作?

它们给出了不同的答案,却得到了相同的分数。因为评分标准——测试用例——是事先定好的。

在 AI 编码时代,写测试不再是"为了验证代码",而是"为了定义问题"。如果你能清晰地定义问题,那么答案——无论是 AI 给的还是自己写的——都不再重要。因为正确的测试自然会把代码引向正确的方向。


附录:完整测试用例

两个会议

会议开始时间结束时间
Meeting 108:0009:00
Meeting 208:3010:00

时间关系:Meeting 1 和 Meeting 2 互相重叠。

用例名称操作步骤预期结果
1 A 2 A => 2 xMeeting 1 分配到 A,Meeting 2 分配到 AMeeting 1 正常,Meeting 2 冲突
1 A 2 A -> 1 null =>上述基础上,Meeting 1 移出两个会议都正常
1 A 2 A -> 1 B =>上述基础上,Meeting 1 移到 B两个会议都正常
1 A 2 A -> 2 null =>上述基础上,Meeting 2 移出两个会议都正常
1 A 2 A -> 2 B =>上述基础上,Meeting 2 移到 B两个会议都正常

三个会议 - 全重叠

会议开始时间结束时间
Meeting 108:0009:30
Meeting 208:3009:30
Meeting 309:0010:00

时间关系:三个会议两两之间都互相重叠。

用例名称操作步骤预期结果
1 A 2 A 3 A => 2 x 3 x初始状态Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 1 null => 2 x 3 xMeeting 1 移出Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 2 null => 3 xMeeting 2 移出Meeting 1 正常,Meeting 2 正常,Meeting 3 冲突
1 A 2 A 3 A -> 3 null => 2 xMeeting 3 移出Meeting 1 正常,Meeting 2 冲突,Meeting 3 正常
1 A 2 A 3 A -> 1 B => 2 xMeeting 1 移到 BMeeting 1 正常,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 2 B => 3 xMeeting 2 移到 BMeeting 1 正常,Meeting 2 正常,Meeting 3 冲突
1 A 2 A 3 A -> 3 B => 2 xMeeting 3 移到 BMeeting 1 正常,Meeting 2 冲突,Meeting 3 正常

三个会议 - 交叉重叠

会议开始时间结束时间
Meeting 108:0009:00
Meeting 208:3009:30
Meeting 309:0010:00

时间关系:Meeting 1 与 Meeting 2 重叠,Meeting 2 与 Meeting 3 重叠,Meeting 1 与 Meeting 3 不重叠(首尾相接)。

场景一:1 A 2 A 3 A(按 1、2、3 顺序分配到会议室 A)
用例名称操作步骤预期结果
1 A 2 A 3 A => 2 x 3 x初始状态Meeting 1 正常,Meeting 2 冲突,Meeting 3 冲突
1 A 2 A 3 A -> 1 null => 2 xMeeting 1 移出Meeting 1 正常,Meeting 2 冲突,Meeting 3 正常
1 A 2 A 3 A -> 2 null =>Meeting 2 移出三个会议都正常
1 A 2 A 3 A -> 3 null => 2 xMeeting 3 移出Meeting 1 正常,Meeting 2 冲突,Meeting 3 正常
1 A 2 A 3 A -> 1 B => 2 xMeeting 1 移到 BMeeting 1 正常,Meeting 2 冲突,Meeting 3 正常
1 A 2 A 3 A -> 2 B =>Meeting 2 移到 B三个会议都正常
1 A 2 A 3 A -> 3 B => 2 xMeeting 3 移到 BMeeting 1 正常,Meeting 2 冲突,Meeting 3 正常
场景二:2 A 1 A 3 A(按 2、1、3 顺序分配到会议室 A)
用例名称操作步骤预期结果
2 A 1 A 3 A => 1 x 3 x初始状态Meeting 1 冲突,Meeting 2 正常,Meeting 3 冲突
2 A 1 A 3 A -> 1 null => 1 xMeeting 1 移出Meeting 1 正常,Meeting 2 正常,Meeting 3 冲突
2 A 1 A 3 A -> 2 null =>Meeting 2 移出三个会议都正常
2 A 1 A 3 A -> 3 null => 1 xMeeting 3 移出Meeting 1 冲突,Meeting 2 正常,Meeting 3 正常
2 A 1 A 3 A -> 1 B => 2 xMeeting 1 移到 BMeeting 1 正常,Meeting 2 正常,Meeting 3 冲突
2 A 1 A 3 A -> 2 B =>Meeting 2 移到 B三个会议都正常
2 A 1 A 3 A -> 3 B => 1 xMeeting 3 移到 BMeeting 1 冲突,Meeting 2 正常,Meeting 3 正常

四个会议 - 链式重叠

会议开始时间结束时间
Meeting 108:0009:00
Meeting 208:3009:30
Meeting 309:0010:00
Meeting 409:3010:30

时间关系:链式重叠,1-2 重叠,2-3 重叠,3-4 重叠,1-3、1-4、2-4 不重叠。

场景一:1 A 2 A 3 A 4 A(按时间顺序分配到会议室 A)
用例名称操作步骤预期结果
1 A 2 A 3 A 4 A => 2 x 3 x 4 x初始状态Meeting 1 正常,Meeting 2、3、4 冲突
1 A 2 A 3 A 4 A -> 1 null => 2 x 3 xMeeting 1 移出Meeting 1 正常,Meeting 2、3 冲突,Meeting 4 正常
1 A 2 A 3 A 4 A -> 2 null => 3 xMeeting 2 移出Meeting 1、2、4 正常,Meeting 3 冲突
1 A 2 A 3 A 4 A -> 3 null => 2 xMeeting 3 移出Meeting 1、3、4 正常,Meeting 2 冲突
1 A 2 A 3 A 4 A -> 4 null => 2 x 3 xMeeting 4 移出Meeting 1、4 正常,Meeting 2、3 冲突
1 A 2 A 3 A 4 A -> 1 B => 2 x 3 xMeeting 1 移到 BMeeting 1 正常,Meeting 2、3 冲突,Meeting 4 正常
1 A 2 A 3 A 4 A -> 2 B => 3 xMeeting 2 移到 BMeeting 1、2、4 正常,Meeting 3 冲突
1 A 2 A 3 A 4 A -> 3 B => 2 xMeeting 3 移到 BMeeting 1、3、4 正常,Meeting 2 冲突
1 A 2 A 3 A 4 A -> 4 B => 2 x 3 xMeeting 4 移到 BMeeting 1、4 正常,Meeting 2、3 冲突
场景二:2 A 1 A 4 A 3 A(按 2、1、4、3 顺序分配到会议室 A)
用例名称操作步骤预期结果
2 A 1 A 4 A 3 A => 1 x 3 x初始状态Meeting 1、3 冲突,Meeting 2、4 正常
2 A 1 A 4 A 3 A -> 1 null => 3 xMeeting 1 移出Meeting 1、2、4 正常,Meeting 3 冲突
2 A 1 A 4 A 3 A -> 2 null => 3 xMeeting 2 移出Meeting 1、2、4 正常,Meeting 3 冲突
2 A 1 A 4 A 3 A -> 3 null => 1 xMeeting 3 移出Meeting 2、3、4 正常,Meeting 1 冲突
2 A 1 A 4 A 3 A -> 4 null => 1 x 3 xMeeting 4 移出Meeting 1、3 冲突,Meeting 2、4 正常
2 A 1 A 4 A 3 A -> 1 B => 3 xMeeting 1 移到 BMeeting 1、2、4 正常,Meeting 3 冲突
2 A 1 A 4 A 3 A -> 2 B => 3 xMeeting 2 移到 BMeeting 1、2、4 正常,Meeting 3 冲突
2 A 1 A 4 A 3 A -> 3 B => 1 xMeeting 3 移到 BMeeting 2、3、4 正常,Meeting 1 冲突
2 A 1 A 4 A 3 A -> 4 B => 1 x 3 xMeeting 4 移到 BMeeting 1、3 冲突,Meeting 2、4 正常