🌿 Specification Driven Development

AI 生成更多代码之后,
质量控制要从代码上移到规格。

目标不是取消 Code Review,而是建立一个真正的 SSOT:需求、业务规则和 BDD 场景聚合在同一份 Spec 中;技术实现和测试都直接围绕它工作,不再重复翻译和维护多份不同视角的副本。

01 · 问题 🌱

代码吞吐量在增长,但人的 Review 带宽没有同步增长

在 Vibe Coding / Coding Agent 场景下,如果所有质量责任仍然压在“逐行人工读代码”上,Review 很快就会成为瓶颈。更深层的问题是:同一个需求往往被分别翻译成 PRD、研发理解、技术方案、测试用例和代码,信息在多次转述中不断漂移。

传统链路:同一需求被多次翻译

PRD研发理解 代码测试再翻译

产品、研发、测试分别维护不同视角的副本。任何一次需求变化,都需要人工同步多份材料,越到后期越容易出现语义漂移。

主要瓶颈人工阅读实现细节

目标链路:一个 Spec 作为 SSOT

Spec = Requirement + Rules + BDDImplementation +Executable TestsEvidence

Spec 本身同时承载需求、规则和 BDD 场景。实现代码直接实现 Spec,测试代码直接验证 Spec,不再额外维护一份测试视角的需求副本。

人工关注Intent / Boundary / Risk
02 · 方法 🍃

BDD 不是另一份文档,而是 Spec 内部的可执行行为表达

产品需求、业务规则、验收标准和 BDD 场景直接聚合在同一份 Specification 中。研发补充技术约束,测试补充边界与复杂场景,但不是各自复制一份需求,而是共同修改同一个 SSOT。

STEP 01定义行为把需求、规则、验收标准直接沉淀进同一个 Spec,并在其中写出可判定的 BDD 场景。
STEP 02转成可执行约束测试直接引用 Spec 中的场景标识和行为定义,把同一份 Spec 转成机器可持续验证的约束。
STEP 03让实现对约束负责Agent 可以反复生成或修改实现;只要实现或测试与 Spec 不一致,流水线就不能通过。
01统一 Spec
02规则 + BDD
03技术实现
04可执行测试
05验证证据
06Human Gate
03 · 极小 Demo 🪴

先从一条很普通的产品需求开始

示例故意保持简单:连续输错密码 5 次后锁定账号 30 分钟。重点是看需求描述和 BDD 场景如何直接聚合在同一个 Spec 中,然后由实现代码和测试代码分别去实现、验证这一个 SSOT。

Product Requirement · AUTH-241

连续输错密码后锁定账号

为了降低暴力破解风险,当用户连续输错密码达到阈值时,系统应临时锁定账号。

原始验收标准

连续输错 5 次后锁定 30 分钟;锁定期间不能继续登录。

auth-lockout.spec.md · SSOT 等待整理
# SSOT Specification

点击左侧按钮,将原始需求直接沉淀到同一个 Spec:
- 需求目标
- 业务规则
- BDD 场景
- 边界场景
- 需要人工确认的决策
04 · BDD 规格 → Go 可执行测试 🌿

代码里的场景顺序,和 BDD 用例保持一一对应

左边不是额外维护的一份 BDD 文档,而是 SSOT Spec 中的场景片段;右边的测试代码直接对应这个片段。为了方便产品、研发、测试共同 Review,测试代码严格按照“假如 → 当 → 那么 → 并且”的顺序映射。

auth-lockout.spec.md · SSOTBDD 用例
场景 AUTH-LOCK-001:第 5 次失败触发锁定

假如:用户已经连续失败 4 次

当:第 5 次密码校验失败

那么:账号立即锁定 30 分钟

并且:本次请求返回 locked=true
lockout_policy_test.goGinkgo v2 + Gomega
var _ = Describe("场景 AUTH-LOCK-001:第 5 次失败触发锁定", func() {
  var (
    时钟   *FakeClock
    存储   *MemoryAttemptStore
    策略   *LockoutPolicy
    用户ID = "u-42"
  )

  Context("假如:用户已经连续失败 4 次", func() {
    BeforeEach(func(ctx SpecContext) {
      时钟 = NewFakeClock(
        time.Date(2026, 8, 7, 10, 0, 0, 0, time.UTC),
      )
      存储 = NewMemoryAttemptStore()
      策略 = NewLockoutPolicy(存储, 时钟, 5, 30*time.Minute)

      for i := 0; i < 4; i++ {
        已锁定, err := 策略.RecordFailure(ctx, 用户ID)
        Expect(err).NotTo(HaveOccurred())
        Expect(已锁定).To(BeFalse())
      }
    })

    When("当:第 5 次密码校验失败", func() {
      It("那么:账号立即锁定 30 分钟;并且:本次请求返回 locked=true",
        func(ctx SpecContext) {
          已锁定, err := 策略.RecordFailure(ctx, 用户ID)

          Expect(err).NotTo(HaveOccurred())

          // 那么:账号立即进入锁定状态
          Expect(已锁定).To(BeTrue())

          // 那么:锁定时间精确为 30 分钟
          Expect(存储.LockedUntil(用户ID)).
            To(Equal(时钟.Now().Add(30 * time.Minute)))

          // 并且:本次请求返回 locked=true
          Expect(已锁定).To(BeTrue())
        },
      )
    })
  })
})
场景
BDD 的“第 5 次失败触发锁定”,对应 Ginkgo 最外层 Describe,方便测试报告直接显示业务场景。
假如
前 4 次失败在 BeforeEach 中构造,且每一次都断言“尚未锁定”,保证前置状态本身也是可信的。
第 5 次失败单独放在 When 中,它就是触发行为变化的动作。
那么 / 并且
锁定状态、30 分钟截止时间、返回结果都变成明确断言。任何一个不满足,Agent 的实现都不能通过。
05 · 测试如何约束实现

实现代码可以被 AI 重写,但分支必须满足同一份业务规则

这里不要求业务同学理解每一行实现。真正需要对照的是:实现代码中的关键业务分支,是否直接对应同一份 Spec 中已经确认的规则,而不是对应研发自己重新解释的一份需求。

lockout_policy.go实现代码
func (p *LockoutPolicy) RecordFailure(
  ctx context.Context,
  userID string,
) (bool, error) {
  count, err := p.store.IncrementFailures(ctx, userID)
  if err != nil {
    return false, err
  }

  // 分支一:还没达到第 5 次失败,不锁定账号
  if count < p.threshold {
    return false, nil
  }

  // 分支二:达到第 5 次失败,计算 30 分钟锁定窗口
  lockedUntil := p.clock.Now().Add(p.lockDuration)

  // 分支三:把锁定状态写入共享存储
  if err := p.store.Lock(ctx, userID, lockedUntil); err != nil {
    return false, err
  }

  // 分支四:本次请求明确返回 locked=true
  return true, nil
}

从失败到通过:测试是 Agent 的反馈函数

如果实现有 bug
例如把阈值错误写成 count <= 5,第 5 次失败仍返回 false。
$ ginkgo -v ./internal/auth
[FAIL] 场景 AUTH-LOCK-001:第 5 次失败触发锁定
期望:locked=true
实际:locked=false
Agent 修改实现后
只要规格和测试不变,AI 可以反复修改代码,直到所有行为约束重新通过。
$ ginkgo -v ./internal/auth
[PASS] 场景 AUTH-LOCK-001:第 5 次失败触发锁定
[PASS] 场景 AUTH-LOCK-002:锁定期间输入正确密码仍不能登录
[PASS] 场景:登录成功后清零连续失败次数

3 个场景 · 3 通过 · 0 失败
06 · BDD 单测不够时 🍃

规则用 BDD 快速验证;组合风险用 Integration / E2E 补齐

“账号第 5 次失败应该锁定”已经定义在同一个 Spec 中。服务级 BDD、Integration 和 E2E 只是针对这份 SSOT 提供不同强度的验证证据,而不是分别维护三套需求描述。

BDD / Unit
状态转换、边界值、核心业务规则。使用 Fake / Mock,让反馈尽可能快。
高频 · 秒级
Integration
真实 Redis / DB / MQ、事务、序列化、Repository 行为。
中频 · 秒到分钟
E2E
多服务协同、并发竞争、幂等、故障恢复、跨实例一致性、复杂业务流程。
精选 · 分钟级
复杂场景补充

场景 AUTH-LOCK-E2E-001:20 个并发失败请求,跨 3 个应用实例

这里关注的已经不是某个函数返回值,而是共享状态和副作用在真实并发下是否仍然正确。

假如:3 个应用实例共享同一个 Redis。
当:20 个请求同时提交错误密码。
那么:最终账号必须进入锁定状态。
并且:安全锁定事件只能发送一次。
并且:三个实例读取到的锁定状态必须一致。
并发请求 × 20 应用实例 × 3 Redis 安全事件
lockout_e2e_test.goGinkgo + Testcontainers
var _ = Describe("场景 AUTH-LOCK-E2E-001:多实例并发失败仍只触发一次锁定事件", Ordered, func() {
  var 测试环境 *TestEnv

  BeforeAll(func(ctx SpecContext) {
    // 假如:启动真实 Redis,并启动 3 个共享 Redis 的应用实例
    测试环境 = StartTestEnv(
      ctx,
      WithRealRedisContainer(),
      WithAppReplicas(3),
    )
    DeferCleanup(测试环境.Close)
  })

  Context("假如:3 个应用实例共享同一个 Redis", func() {
    When("当:20 个请求同时提交错误密码", func() {
      It("那么:账号最终被锁定;并且:安全事件只发送一次;并且:三个实例状态一致",
        func(ctx SpecContext) {
          const 并发数 = 20
          用户ID := "u-concurrent"

          var 等待组 sync.WaitGroup
          for i := 0; i < 并发数; i++ {
            等待组.Add(1)

            go func() {
              defer 等待组.Done()
              _ = 测试环境.API.
                RandomReplica().
                Login(ctx, 用户ID, "错误密码")
            }()
          }

          等待组.Wait()

          // 那么:账号最终进入锁定状态
          Eventually(func() bool {
            状态, err := 测试环境.API.GetAccountState(ctx, 用户ID)
            return err == nil && 状态.Locked
          }).Should(BeTrue())

          // 并且:安全锁定事件只发送一次
          Eventually(func() int {
            return 测试环境.RiskEvents.
              Count("account_locked", 用户ID)
          }).Should(Equal(1))

          // 并且:三个实例读取到的锁定状态必须一致
          Consistently(func() bool {
            return 测试环境.AllReplicasAgreeLocked(ctx, 用户ID)
          }).Should(BeTrue())
        },
      )
    })
  })
})
复杂业务流程
例如下单 → 扣库存 → 支付 → 发券 → 退款,更适合用跨服务 E2E 验证编排和补偿。
并发与一致性
抢购、重复回调、账户锁、库存扣减,需要验证竞争条件、幂等和唯一副作用。
真实基础设施
使用 Testcontainers 在测试生命周期内启动 Redis / DB / MQ,减少“Mock 通过但线上不工作”。
07 · 最终工作流 🌱

让需求 Spec、技术实现、测试用例始终指向同一个 SSOT

Spec 是唯一的需求与行为事实源。技术设计、实现代码、测试代码当然仍然存在,但它们不再保存一份自己的“需求副本”,而是通过场景 ID、规则 ID 或引用关系直接对齐到同一个 Spec。

SSOT Spec需求 + 规则 + BDD 场景
Design引用 Spec 的技术决策
Implementation直接实现 Spec
BDD Tests直接验证 Spec 场景
Integration验证真实依赖
E2E Tests验证复杂业务风险
EvidenceSpec → Test → Result
PR / Release基于同一 Spec 决策

机器持续检查

BDD / Unit / Integration / E2E 是否全部通过
Specification 的关键场景是否都有验证证据
静态检查、安全规则、常规质量门槛
代码变化是否影响已有行为与回归范围

人主要 Review

业务规则是否正确、完整、无歧义
边界场景和复杂风险有没有遗漏
技术设计是否引入系统性风险
当前自动化证据是否足以支持上线决策
模拟一次完整 Pipeline
从规格确认,到 BDD、实现、E2E,再到 Human Gate。
规格确认行为与边界
BDD规则验证
实现Agent 修改
E2E复杂风险
证据覆盖 / 回归
Human Gate上线判断
Ready.
08 · 对团队的价值 🌸

最终目标:需求 Spec、技术实现、测试用例完全对齐,一个 SSOT

Product 🌱

需求和 BDD 本来就是同一个 Spec

  • 歧义更早暴露
  • 验收标准不止停留在文档
  • 变更影响更容易追踪
Engineering 🌿

Agent 有明确的反馈函数

  • 不是生成完再人工兜底
  • 测试失败直接驱动修正
  • 人工聚焦架构与风险
QA 🍃

从重复翻译需求转向补风险

  • 测试直接引用同一个 Spec
  • 重点补异常、并发和 E2E
  • 测试经验反哺 Specification
Team 🌸

让产品知识只维护一份

  • 减少文档漂移
  • 降低人员与 Agent 上下文成本
  • 关键行为具备持续证据
核心不是再引入一套 BDD 文档体系。
而是让需求、规则和 BDD 场景只存在于一个 SSOT Spec 中;技术实现和测试都直接对齐它。任何需求变化只改一个地方,从源头减少重复翻译、双向同步和信息漂移。