当AI TDD遇上轻量Spec

一、引言

在之前的一篇文章《AI TDD 开发方式初体验》中,我分享了使用 AI 辅助 TDD 开发的初步尝试,那次体验让我看到了 AI 辅助编码的巨大潜力,但也暴露出一个问题:

AI 生成的代码结构和命名往往不可控。

同样的需求,AI 可能给出完全不同的文件结构、类名、方法名。这对于个人项目或许无妨,但在团队协作中,这种不确定性是致命的。

如何让 AI 在 TDD 流程中输出可控、可预期的代码?

答案是:轻量Spec

本文将以一个完整的"猜数字"游戏为例,展示当 AI TDD 遇上轻量Spec,会碰撞出怎样的火花。

AI TDD+轻量Spec完整流.png

二、什么是轻量Spec

轻量Spec结构思维导图.png

2.1 定义

轻量Spec 是介于需求文档代码实现之间的一层轻量级设计规格。

需求文档 → 轻量Spec → 代码
  (做什么)    (怎么组织)   (具体实现)

它不是详细设计文档,不需要画 UML 图,也不需要写伪代码。它只需要回答几个关键问题:

  • 有哪些类/函数?叫什么名字?
  • 放在哪些文件里?
  • 测试策略是什么?

2.2 轻量Spec 的结构

以下是本次猜数字游戏的完整轻量Spec:

Module: GuessNumber Game

Rules:
  - 答案是 4 个互不重复的数字(0~9)
  - 玩家每次输入也是 4 个互不重复的数字
  - A = 数字与位置都一致的个数
  - B = 数字存在但位置不一致的个数
  - 返回格式:"{A}A{B}B"
  - 猜中即得到 "4A0B",游戏结束

Attempts:
  - 玩家最多可猜 6 次
  - 超过 6 次仍未猜中 → 返回 "You are lose"

History:
  - 系统记录每次 Guess 的输入与结果(A/B)
  - 可通过 GetHistory() 获取历史记录(按顺序保存)

Invariants:
  - 输入必须为 4 位不重复数字,否则返回 error
  - 游戏结束后再次 Guess → 返回 error

Design:
  - 设计类为Game,构造时注入答案,核心方法为Guess,文件名为game.go
  - 答案生成属于领域规则,函数名为GenerateAnswer,文件名为game_service.go
  - API为函数Guess,文件名为api.go
  - 故事级测试考虑Guess,GenerateAnswer使用gomonkey/v2打桩,测试文件为api_test.go
  - 任务级测试仅考虑GenerateAnswer,测试文件为game_service_test.go
  - Game类不单独测试

可以看到,轻量Spec 包含五个部分:

部分 作用
Rules 核心业务规则,AI 生成代码的逻辑依据
Attempts 边界条件,约束游戏流程
History 功能需求,明确 API 接口
Invariants 不变式,定义异常处理规则
Design 架构设计,约束代码结构和命名

这样的结构既包含了业务规则(做什么),又包含了技术设计(怎么组织),让 AI 既能理解需求,又能输出符合预期的代码结构。

2.3 特点

特点 说明
轻量 几行到几十行,AI 辅助10分钟写完
聚焦 只关注结构,不关注实现细节
够用 足够指导 AI,又不过度设计

三、为什么需要轻量Spec

有无轻量Spec对比.png

3.1 没有 Spec 时的 AI 输出

让 AI 直接根据需求开发,可能得到这样的结构:

guess-number/
├── main.go
├── game/
│   ├── game.go
│   ├── game_test.go
│   └── generator.go
├── handler/
│   └── handler.go
└── models/
    └── record.go

问题来了:

  • 为什么要分 game 和 handler 两个包?
  • generator.go 和 game.go 为什么要分开?
  • 每个类都有测试,是否过度测试?

不同时间、不同上下文,AI 给出的结构可能完全不同。

3.2 有 Spec 时的 AI 输出

有了轻量Spec,AI 生成的结构完全可控:

guess-number/
├── api.go              ← 指定的
├── api_test.go         ← 指定的
├── game.go             ← 指定的
├── game_service.go     ← 指定的
└── game_service_test.go← 指定的

每个文件的名字、每个函数的命名,都和 Spec 一致。

3.3 对比总结

维度 无 Spec 有 Spec
文件结构 随机 可控
命名规范 不一致 统一
测试策略 AI 自行决定 人为指定
返工成本
团队协作 困难 顺畅

四、AI TDD 的红绿重构循环

4.1 经典 TDD 循环

TDD 的核心是红-绿-重构循环:

🔴 红:写一个失败的测试
       ↓
🟢 绿:写最少的代码让测试通过
       ↓
🔄 重构:优化代码,保持测试通过
       ↓
    (回到红,开始下一个循环)

4.2 AI TDD 的变化

在 AI TDD 中,这个循环变成了人机协作:

阶段 传统 TDD AI TDD
🔴 红 人写测试 AI 生成测试,人审查
🟢 绿 人写实现 AI 生成实现,人审查
🔄 重构 人重构 AI 重构,人审查

人的角色从"编码者"变成了"审查者"和"决策者"。

4.3 轻量Spec 在循环中的作用

轻量Spec 就像一份契约,约束 AI 在每个阶段的输出:

         轻量Spec(约束)
              ↓
🔴 红:AI 生成测试 → 文件名、函数名、打桩方式都符合 Spec
              ↓
🟢 绿:AI 生成实现 → 类名、方法名、文件结构都符合 Spec
              ↓
🔄 重构:AI 优化代码 → 保持结构不变,只优化内部实现

五、实战:猜数字游戏开发

5.1 准备工作

开发前,我准备了以下文件:

requirement.md(需求)

猜数字游戏规则:
- 输入4个0~9中不同的数字
- 返回格式"?A?B",A表示数字和位置都对,B表示数字对位置不对
- 猜中显示"4A0B",游戏结束
- 6次没猜中,显示"You are lose"

story.md(故事拆分)

故事 描述
故事1 计算数字和位置都相同的个数(A)
故事2 计算数字相同但位置不同的个数(B)
故事3 控制玩家尝试次数(最多6次)
故事4 记录猜数历史

spec.md(轻量Spec)

Module: GuessNumber Game

Rules:
  - 答案是 4 个互不重复的数字(0~9)
  - A = 数字与位置都一致的个数
  - B = 数字存在但位置不一致的个数
  - 返回格式:"{A}A{B}B"

Attempts:
  - 玩家最多可猜 6 次
  - 超过 6 次仍未猜中 → 返回 "You are lose"

History:
  - 系统记录每次 Guess 的输入与结果
  - 可通过 GetHistory() 获取历史记录

Invariants:
  - 输入必须为 4 位不重复数字,否则返回 error
  - 游戏结束后再次 Guess → 返回 error

Design:
  - 设计类为Game,核心方法为Guess,文件名为game.go
  - 答案生成函数名为GenerateAnswer,文件名为game_service.go
  - API为函数Guess,文件名为api.go
  - 故事级测试使用gomonkey/v2打桩,测试文件为api_test.go
  - Game类不单独测试

tcode-rule.mdc(测试代码规则)

请你作为TDD专家级程序员,按照以下要求生成测试代码:
- 测试代码仅仅能够满足当前故事就可以,不要考虑后续故事
- 测试代码要求是GWT格式(given-when-then三段式)
- 使用goconvey测试框架

示例:
Convey("测试场景", t, func() {
    Convey("given 前置条件", func() {
        Convey("when 执行操作", func() {
            Convey("then 验证结果", func() {
                So(result, ShouldEqual, expected)
            })
        })
    })
})

tdd-rule.mdc(AI 开发流程)

1. 选择第一个故事
2. 根据当前故事和轻量Spec生成测试代码
3. 根据测试代码和轻量Spec生成产品代码
4. 测试成功(失败则自动修复,最多3次)
5. 重构代码
6. 测试成功
7. 选择下一个故事,循环直到结束

5.2 红绿重构实战

一句指令启动开发:

@spec.md @requirement.md @story.md @tdd-rule.mdc 
请开发该需求

故事1:实现 xA 判断

🔴 红:生成测试代码

AI 生成了符合 Spec 的测试文件 api_test.go

func TestGuess_Story1_PositionMatch(t *testing.T) {
    Convey("测试数字和位置都相同的个数", t, func() {
        Convey("given 答案为1234,输入全部匹配", func() {
            ResetGame()
            patches := gomonkey.ApplyFunc(GenerateAnswer, func() string {
                return "1234"
            })
            defer patches.Reset()

            Convey("when 输入1234", func() {
                result, err := Guess("1234")

                Convey("then 返回4A0B", func() {
                    So(err, ShouldBeNil)
                    So(result, ShouldEqual, "4A0B")
                })
            })
        })
    })
}

注意 AI 生成的测试代码同时符合两个规则:

  • 测试文件名 api_test.go ✅ 符合 Spec 中的 Design
  • 使用 gomonkey 打桩 ✅ 符合 Spec 中的 Design
  • GWT 三段式结构(given-when-then)✅ 符合 tcode-rule
  • 使用 goconvey 框架 ✅ 符合 tcode-rule
  • 仅测试当前故事 ✅ 符合 tcode-rule

🟢 绿:生成产品代码

AI 生成了三个文件:

game.go(Game 类):

type Game struct {
    answer string
}

func NewGame(answer string) *Game {
    return &Game{answer: answer}
}

func (g *Game) Guess(input string) (string, error) {
    // 验证输入
    if err := g.validateInput(input); err != nil {
        return "", err
    }
    // 计算A
    a := g.countA(input)
    return fmt.Sprintf("%dA0B", a), nil
}

api.go(API 层):

func Guess(input string) (string, error) {
    if game == nil {
        answer := GenerateAnswer()
        game = NewGame(answer)
    }
    return game.Guess(input)
}

game_service.go(答案生成):

func GenerateAnswer() string {
    // Fisher-Yates 洗牌算法生成4位不重复数字
    ...
}

运行测试:

$ go test -v -gcflags=all=-l ./...

=== RUN   TestGuess_Story1_PositionMatch
  测试数字和位置都相同的个数 
    given 答案为1234,输入全部匹配 
      when 输入1234 
        then 返回4A0B ✔✔
--- PASS

🔄 重构:本轮无需重构

故事2:实现 xB 判断

🔴 红:添加测试

Convey("given 答案为1234,数字全对但位置全错", func() {
    Convey("when 输入4321", func() {
        result, err := Guess("4321")
        Convey("then 返回0A4B", func() {
            So(result, ShouldEqual, "0A4B")
        })
    })
})

🟢 绿:添加 countB 方法

func (g *Game) countB(input string) int {
    count := 0
    for i := 0; i < 4; i++ {
        for j := 0; j < 4; j++ {
            if i != j && input[i] == g.answer[j] {
                count++
                break
            }
        }
    }
    return count
}

测试通过 ✅

故事3 & 故事4

按同样的红绿重构循环,AI 完成了:

  • 尝试次数控制(最多6次)
  • 历史记录功能

5.3 开发成果

项目结构(完全符合 Spec):

guess-number/
├── cmd/main.go           # 主程序
├── api.go                # API 层
├── api_test.go           # 故事级测试
├── game.go               # Game 类
├── game_service.go       # 答案生成
├── game_service_test.go  # 任务级测试
└── README.md

测试结果

=== RUN   TestGuess_Story1_PositionMatch    ✔✔✔✔✔✔
=== RUN   TestGuess_Story2_DigitMatch       ✔✔✔✔✔✔
=== RUN   TestGuess_Story3_Attempts         ✔✔✔✔
=== RUN   TestGuess_Story4_History          ✔✔✔✔✔✔
=== RUN   TestGenerateAnswer                ✔✔✔✔✔✔✔✔✔✔

34 total assertions
PASS

4个故事,从零到完成,约7分钟


六、轻量Spec 的最佳实践

6.1 推荐的 Spec 结构

部分 内容 作用
Rules 核心业务规则 AI 理解"做什么"
Invariants 边界条件、异常处理 AI 处理边界情况
Design 架构、命名、文件结构 AI 输出可控

6.2 配套的测试规则(tcode-rule)

规则 目的
仅满足当前故事 避免过度设计
GWT 三段式 测试结构清晰
指定测试框架 统一技术栈

6.3 不该写什么

内容 原因
算法细节 让 AI 自由发挥
具体实现 这是 AI 的工作
过多约束 会限制 AI 的能力

6.3 粒度把握

太粗:只写"实现猜数字游戏" → AI 输出不可控
太细:写出所有函数签名 → 不如自己写代码
刚好:架构 + 命名 + 测试策略 → 可控且高效

原则:够用就好。


七、总结与思考

核心公式简洁版.png

7.1 核心公式

AI TDD + 轻量Spec = 人做设计决策,AI 做红绿重构
  • 人的职责:需求分析、架构设计、轻量Spec、代码审查
  • AI 的职责:生成测试、生成实现、自动重构

7.2 适用场景

场景 是否适用
团队项目 ✅ 保持风格一致
需要与现有代码集成 ✅ 遵循现有规范
有明确架构要求 ✅ Spec 约束输出
探索性原型 ⚠️ 可以让 AI 自由发挥
简单脚本 ❌ 杀鸡用牛刀

7.3 未来展望

轻量Spec 解决了 AI TDD 的"可控性"问题,让 AI 真正成为可靠的编程伙伴。

未来可以探索:

  • 更复杂业务场景下的轻量 Spec 模板
  • 轻量 Spec 的 AI 辅助生成
  • 多 Agent 协作下的 Spec 分工
  • Spec 与代码一致性的 AI 评审

AI 不会取代程序员,但会用 AI 的程序员将取代不会用的。

而轻量Spec,正是驾驭 AI 的缰绳。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容