AI 写代码快,但你问它"写对了吗"这个问题,它自己回答对了,其实是错误的,这种头脑胜利法,堪比“建国”。下面是我怎么用 OpenSpec 管需求、用 TDD 管正确性,把两者在实际开发中串起来的。
为什么非得这么搞
我开始大量用 AI 辅助写代码,效率确实爆炸式提升。但用了一段时间之后,发现一个很不安的事情:AI 生成的代码总是看起来很对。
它不会给你报错,不会说"这里我不确定",写出来的代码结构清晰、命名规范、注释齐全——简直是模范代码。但跑起来呢?边界情况漏了,业务逻辑理解偏了,有时候甚至会编造一个不存在的 API 然后自信满满地调用它,然后问ta问题解决吗,然后回答说“全部检测通过已经完成”,其实代码编译都不通过。
其实这种现象叫做 AI 的自洽幻觉:它永远能给你一个"合理"的答案,但合理不等于正确。
所以问题就变成了:在代码生成成本越来越低时代,"正确性"才是真正稀缺的东西。 谁来保证正确性?测试。不是写完代码补的那种测试,是 TDD 那种——先写测试,再让 AI 去实现。
但光有 TDD 还不够。测试能告诉你"代码跑得对不对",却没法告诉你"这个需求本身拆得对不对"。需求拆歪了,测试全绿也是白搭。
这就是 OpenSpec 的位置:它管"做什么",TDD 管"做对没"。
整体流程一览
两者的衔接点在 Apply 阶段。前面用 OpenSpec 把意图理清楚、把方案定下来,到了动手环节,每个 task 内部走 TDD 循环。
Explore Propose Apply (TDD 循环) Archive
│ │ │ │
│ 思考讨论 │ proposal.md │ Red → Green → Refactor │ 归档 + sync specs
│ 明确意图 │ design.md │ 逐 task 推进 │
│ │ tasks.md │ │
│ │ specs/ │ │
四个阶段各管各的,但不是死板的瀑布流——你随时可以从 Apply 退回到 Propose 去改设计,也可以跳过 Explore 直接开搞。关键是每个阶段解决的问题不一样:
OpenSpec的使用上篇文章有介绍:# OpenSpec 实战
Tasks 怎么写才对
这是整个方案里最关键的一环。普通的 task 写法是"实现用户登录功能"——这种粒度对 AI 来说太粗了,它会一口气把登录、注册、token 生成全写完,测试?不存在的。
正确的做法是测试 task 和实现 task 成对出现:
## Tasks
- [ ] 为用户登录写失败路径测试(无效密码、不存在用户)
- [ ] 实现登录逻辑使测试通过
- [ ] 为 token 生成写测试(过期、签名验证)
- [ ] 实现 token 生成使测试通过
- [ ] 重构:提取认证中间件
- [ ] 为中间件写集成测试
- [ ] 实现中间件使测试通过
先测试,再实现,中间穿插 refactor。这不是形式主义——这个顺序决定了 AI 在 Apply 阶段的行为模式。如果你不显式写出测试 task,AI 会直接跳到实现,然后补一堆 happy path 的测试交差。
Apply 阶段的 TDD 节奏
每个 task 内部遵循经典的 Red-Green-Refactor 循环:
┌─────────────────────────────────────┐
│ 单个 Task 的执行流程 │
│ │
│ 1. Red → 写测试,运行,确认失败 │
│ 2. Green → 写最少代码让测试通过 │
│ 3. Refactor → 清理代码,测试仍通过 │
│ 4. ✓ 标记 task 完成 │
│ │
└─────────────────────────────────────┘
这里有个容易被忽略的细节:第 1 步的"确认失败"非常重要。如果你写的测试一上来就是绿的,说明两种情况——要么这个功能已经实现了(那这个 task 多余),要么你的测试写得有问题(没测到点上)。不管哪种,都得停下来想想。
"写最少代码"也是有讲究的。AI 特别喜欢过度实现——你让它通过一个测试,它顺手把后面三个 task 的功能也写了。这时候就得在 prompt 里明确约束:只让当前这一个测试变绿,别多做。
实际操作走一遍
拿"给系统加用户认证"这个需求举例:
# 1. Explore - 想清楚要做什么
/opsx:explore add-user-auth
# 讨论:用 JWT 还是 session?要不要第三方登录?密码策略是什么?
# 2. Propose - 生成方案(task 按 TDD 节奏拆分)
/opsx:propose add-user-auth
# 产出 proposal.md / design.md / tasks.md / specs
# 3. Apply - 逐 task 实施
/opsx:apply add-user-auth
Apply 过程中,AI 的实际行为是这样的:
// Task: "为用户登录写失败路径测试"
// AI 写出:
@Test
fun `login with invalid password returns error`() = runTest {
val testUser = userRepository.createUser("test@example.com", "correct_password")
val result = loginUseCase.execute("test@example.com", "wrong_password")
assertThat(result).isInstanceOf(LoginResult.Failure::class.java)
assertThat((result as LoginResult.Failure).reason).isEqualTo("invalid_credentials")
}
@Test
fun `login with nonexistent user returns error`() = runTest {
val result = loginUseCase.execute("nobody@example.com", "any_password")
assertThat(result).isInstanceOf(LoginResult.Failure::class.java)
}
// → 运行测试 → RED ✗(因为 LoginUseCase 还没实现)
// → 标记 [x]
// Task: "实现登录逻辑使测试通过"
// AI 写出:
class LoginUseCase @Inject constructor(
private val userRepository: UserRepository,
private val tokenManager: TokenManager
) {
suspend fun execute(email: String, password: String): LoginResult {
val user = userRepository.findByEmail(email)
?: return LoginResult.Failure("invalid_credentials")
if (!user.verifyPassword(password)) {
return LoginResult.Failure("invalid_credentials")
}
val token = tokenManager.createAccessToken(user.id)
return LoginResult.Success(token)
}
}
// → 运行测试 → GREEN ✓
// → 标记 [x]
# 4. 全部 task 完成后归档
/opsx:archive add-user-auth
Spec 和 Test 的关系
这两者的关系容易混淆,我用一个类比说清楚:Spec 是合同,Test 是验收。
合同写的是"用户连续 5 次登录失败应锁定账户",这是业务语言,给人看的。验收做的是 account locks after 5 failures 这个测试用例,这是代码语言,给机器跑的。
openspec/specs/auth/spec.md ← 合同(业务语言)
"用户连续 5 次登录失败应锁定账户"
app/src/test/java/.../auth/LoginUseCaseTest.kt ← 验收(代码语言)
fun `account locks after 5 consecutive failures`()
每条 spec 应该能映射到至少一个测试——如果有一条 spec 找不到对应的测试,要么是测试漏了,要么是这条 spec 写得太虚(比如"系统应该安全"这种就没法直接测)。
反过来也成立:如果你写了一个测试但找不到对应的 spec,说明你在测一个没被定义过的行为。这种测试不是不能有,但得想想它是不是应该先变成一条 spec。
config.yaml 建议配置
schema: spec-driven
context: |
开发模式:OpenSpec + TDD
测试框架:JUnit5 + Truth + Mockk # Android 项目常用组合
测试目录:app/src/test/ 和 app/src/androidTest/
每个 task 必须先有测试覆盖再写实现
rules:
tasks:
- 测试类 task 和实现类 task 必须成对出现
- 每个实现 task 之前必须有对应的测试 task
- Refactor task 的前提是所有测试通过
design:
- 设计中需包含 testability 考虑
- 标注哪些边界需要 mock/stub
这个配置的作用是约束 AI 在 Propose 阶段生成 tasks 时遵循 TDD 节奏。没有这些 rules,AI 大概率会生成"实现 XX 功能"这种大而化之的 task,测试完全被忽略。
这套方案用了一段时间之后,我最大的感受不是"代码质量提高了",而是我终于可以在 AI 写完代码之后安心去喝杯咖啡了。测试在那儿守着,spec 把意图锁死了,就算 AI 犯了幻觉,也跑不出这个圈。