OpenSpec + TDD:让 AI 写代码,用测试兜底

AI 写代码快,但你问它"写对了吗"这个问题,它自己回答对了,其实是错误的,这种头脑胜利法,堪比"建国"。下面是我怎么用 OpenSpec 管需求、用 TDD 管正确性,把两者在实际开发中串起来的。

为什么非得这么搞

我开始大量用 AI 辅助写代码,效率确实爆炸式提升。但用了一段时间之后,发现一个很不安的事情:AI 生成的代码总是看起来很对

它不会给你报错,不会说"这里我不确定",写出来的代码结构清晰、命名规范、注释齐全------简直是模范代码。但跑起来呢?边界情况漏了,业务逻辑理解偏了,有时候甚至会编造一个不存在的 API 然后自信满满地调用它,然后问ta问题解决吗,然后回答说"全部检测通过已经完成",其实代码编译都不通过。

其实这种现象叫做 AI 的自洽幻觉:它永远能给你一个"合理"的答案,但合理不等于正确。

所以问题就变成了:在代码生成成本越来越低时代,"正确性"才是真正稀缺的东西。 谁来保证正确性?测试。不是写完代码补的那种测试,是 TDD 那种------先写测试,再让 AI 去实现。

但光有 TDD 还不够。测试能告诉你"代码跑得对不对",却没法告诉你"这个需求本身拆得对不对"。需求拆歪了,测试全绿也是白搭。

这就是 OpenSpec 的位置:它管"做什么",TDD 管"做对没"。

整体流程一览

两者的衔接点在 Apply 阶段。前面用 OpenSpec 把意图理清楚、把方案定下来,到了动手环节,每个 task 内部走 TDD 循环。

bash 复制代码
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 成对出现

ini 复制代码
## Tasks

- [ ] 为用户登录写失败路径测试(无效密码、不存在用户)
- [ ] 实现登录逻辑使测试通过
- [ ] 为 token 生成写测试(过期、签名验证)
- [ ] 实现 token 生成使测试通过
- [ ] 重构:提取认证中间件
- [ ] 为中间件写集成测试
- [ ] 实现中间件使测试通过

先测试,再实现,中间穿插 refactor。这不是形式主义------这个顺序决定了 AI 在 Apply 阶段的行为模式。如果你不显式写出测试 task,AI 会直接跳到实现,然后补一堆 happy path 的测试交差。

Apply 阶段的 TDD 节奏

每个 task 内部遵循经典的 Red-Green-Refactor 循环:

arduino 复制代码
┌─────────────────────────────────────┐
│         单个 Task 的执行流程          │
│                                     │
│   1. Red    → 写测试,运行,确认失败   │
│   2. Green  → 写最少代码让测试通过     │
│   3. Refactor → 清理代码,测试仍通过   │
│   4. ✓ 标记 task 完成                │
│                                     │
└─────────────────────────────────────┘

这里有个容易被忽略的细节:第 1 步的"确认失败"非常重要。如果你写的测试一上来就是绿的,说明两种情况------要么这个功能已经实现了(那这个 task 多余),要么你的测试写得有问题(没测到点上)。不管哪种,都得停下来想想。

"写最少代码"也是有讲究的。AI 特别喜欢过度实现------你让它通过一个测试,它顺手把后面三个 task 的功能也写了。这时候就得在 prompt 里明确约束:只让当前这一个测试变绿,别多做。

实际操作走一遍

拿"给系统加用户认证"这个需求举例:

bash 复制代码
# 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 的实际行为是这样的:

kotlin 复制代码
// 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]
kotlin 复制代码
// 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]
bash 复制代码
# 4. 全部 task 完成后归档
/opsx:archive add-user-auth

Spec 和 Test 的关系

这两者的关系容易混淆,我用一个类比说清楚:Spec 是合同,Test 是验收。

合同写的是"用户连续 5 次登录失败应锁定账户",这是业务语言,给人看的。验收做的是 account locks after 5 failures 这个测试用例,这是代码语言,给机器跑的。

kotlin 复制代码
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 建议配置

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 犯了幻觉,也跑不出这个圈。

相关推荐
kfaino8 小时前
码农的AI翻身(三)你好,我叫 Embedding
后端·ai编程
爱勇宝9 小时前
大多数人不是在使用 AI 赚钱,而是在帮 AI 公司赚钱
前端·后端·程序员
冬奇Lab9 小时前
每日一个开源项目(第143篇):page-agent - 纯 JS 的网页 GUI Agent,无需截图、无需插件、无需后端
前端·人工智能·agent
_山海10 小时前
OpenSpec-基于SDD规格驱动开发
ai编程·vibecoding
IT_陈寒14 小时前
React的这个渲染问题连官方文档都没说清楚
前端·人工智能·后端
追逐时光者15 小时前
别再满网找零散工具了,腾讯 QQ 浏览器这个“帮小忙”工具箱真能省时间
前端·后端
Asmewill17 小时前
grep&curl命令学习笔记
前端
唐老板17 小时前
MCP协议实战:从零写个Agent工具
ai编程·mcp
stringwu17 小时前
Flutter 开发必备:MVI 架构的高效实现指南
前端·flutter
counterxing18 小时前
最近发现一个 Mac 工具,有点像把 Raycast、语音输入法、截图和录屏塞到了一起
macos·ai编程·claude