BDD(行为驱动开发)入门:把"测试"写成"行为",把"需求"写成"场景"
缩写科普(先把字面意思说清楚)
BDD = Behaviour/Behavior-Driven Development(行为驱动开发,英式/美式拼写都常见)
B(Behaviour/Behavior,行为):关注"系统应该怎么表现"(可观察的输出/效果),而不是"测了哪些函数"。D(Driven,驱动):用这些行为描述反向驱动后续工作(先写清楚边界与验收口径,再写实现/自动验证)。D(Development,开发):最终目标仍是交付可运行的软件,只是把对齐工作前移。
TDD = Test-Driven Development(测试驱动开发)T(Test,测试):把测试用例当作驱动载体。D(Driven,驱动):典型节奏是"先写一个会失败的测试 -> 写最少实现让它通过 -> 重构"。D(Development,开发):同样是交付软件,但更强调用测试推动设计与实现演进。
摘要(先看结论)
如果你只记住三句话,就够用:
- BDD 想解决的不是"怎么写更多测试",而是"怎么把团队对系统行为的理解写清楚",并让它能自动验证。
- 从代码层面开始的一个小招式是:把测试名写成句子,尤其用
should ...句式,它会逼你把"当前这个类/模块应该做什么"说清楚。- 正例:
OrderService should 库存不足时拒绝下单并给出原因(一句话就能读懂承诺)。 - 反例:
OrderController should 校验地址并计算优惠并预占库存并调用支付...(一句话塞太多事,通常职责放错了)。 - 判断规则:如果你很难把测试名写成"
当前组件 should ...",先别怪文笔,优先怀疑"行为归属不清/边界没切开",考虑拆分组件或把测试搬家。
- 正例:
- 把需求写成
Given / When / Then的场景(验收标准),再把场景拆成可复用的片段,就能把"需求 → 自动验收 → 代码实现"连成一条线。
快速导航(按你的困惑跳读)
- "我看完还是不知道 BDD 是啥":先看「BDD 到底改变了什么」
- "should 到底有啥用":看「把测试名写成句子」和「失败时三种解释」
- "Given/When/Then 是啥关系":看「把需求写成场景」
- "怎么变成可执行的":看「让验收标准可执行:把场景拆成片段」
先画两张图(建立直觉)
下面这两张图就是全文主线。
- 从"想要什么"到"能自动验证"的闭环:
text
用户故事(想要/价值)
|
v
验收标准(场景:Given/When/Then)
|
v
场景片段(Given / When / Then 的可复用步骤)
|
v
可执行代码(真实对象 or mock) --> 运行得到反馈
- 从"测试思维"切换到"行为思维"的变化:
text
以前:我在写测试(test) -> 关注"测哪里、测多少、怎么命名"
现在:我在描述行为(behaviour) -> 关注"系统应该怎么表现"
测试失败时:
- 我引入了 bug
- 行为迁移到别处了(测试应该搬家)
- 行为不再正确(测试应该删掉)
BDD 到底改变了什么
很多人学 TDD 会卡在一组典型问题上:从哪里开始?该测什么不该测什么?一次写多少?名字怎么取?失败了怎么判断是代码错还是假设错?
BDD 的切入点非常朴素:先把你关心的"系统行为"用一句话讲清楚,再让这句话变成可执行的验收标准。换句话说:
- "test" 容易让人把注意力放在测试本身(数量、覆盖率、写法)。
- "behaviour" 更容易让人把注意力放在行为本身(系统应该如何表现)。
这不是文字游戏,而是一个写作约束:当你被迫把方法名写成一句话时,你会更容易发现"这个行为到底属于谁"。
把测试名写成句子(尤其是 should)
一个非常小、但很有效的实践是:把测试方法名当作一句能读懂的人话。比如在"电商下单"场景里,把一组测试名渲染成:
text
下单(OrderService)
- should 计算订单应付金额(含优惠券/运费)
- should 库存不足时拒绝下单并给出原因
- should 支付超时后自动取消并释放库存
你会立刻得到两个收益:
- 失败时更好读:CI 红了,你先看到的是"应该怎么表现",而不是一串工具/实现细节。
- 更像"文档":把一组行为列出来,别人一眼就知道这个模块承诺了什么。
接着再加一个更强的约束:用 should 开头。
The class should do something 的隐含规则是:你只能描述"当前这个类/模块的行为"。如果你发现一个测试名很难写进这个句式里,往往不是你文笔差,而是"职责可能放错地方了"。
一个典型例子是:你把"风控校验""地址合规校验""优惠分摊""库存预占"等逻辑都塞进了 OrderController,很快就会写出这种别扭的测试名:
OrderController should 校验地址并计算优惠并预占库存并调用支付并记录埋点...
这通常暗示:这些行为并不"属于 Controller",它更像是被迫承担了太多职责。更合理的拆法是把行为迁到更内聚的组件里(例如 PricingService / InventoryReservation / RiskCheckPolicy),让每个组件的 should 测试都能写成一句清晰的人话。
此时 OrderController 的测试应当收敛到它真正的职责:例如"它会调用下单用例并正确映射错误码",而不是把"优惠怎么算/库存怎么占/风控怎么判"全部塞进同一个类的测试里。
测试失败时,先问一句:它真的 should 吗?
当你用 should 命名时,它还有一个微妙但关键的好处:你可以反问自己一句:
"它真的应该这样吗?"
这句反问会把你从"测试失败了我该修测试/修代码"的焦虑里拉出来,回到更本质的判断:你到底碰到了哪一种情况?
- 我引入了 bug:行为仍然正确,但实现错了。
- 行为迁移了:行为仍然正确,但它现在由别的组件承担,测试应该搬家(顺便重命名/调整)。
- 行为不再正确:系统前提变了,这条行为不再成立,测试应该删掉。
这第三条很重要:删掉不再正确的测试不是"变差",而是"前提更新"。
从"代码行为"走到"需求行为":把需求写成场景
当你把注意力放到"行为"上,你会自然发现:需求本质上也是行为。
一个常见的用户故事模板是:
text
作为一名 [X]
我想要 [Y]
以便 [Z]
这个模板的价值在于:它逼你把"价值"写出来(Z)。写不出价值时,需求往往只是"我就是想要"。
接下来把"这个需求什么时候算交付了?"回答成验收标准。BDD 常用的写法是用场景(Scenario)表示,并用 Given / When / Then 描述:
text
Given 某些前置条件(上下文)
When 某个事件发生
Then 应该出现哪些结果
你可以把它当作一种结构化叙事:先交代背景,再发生动作,再给出可检查的结果。
一个具体例子:ATM 取现(两条场景)
用户故事(要的是"价值"):
text
标题:客户取现金
作为一名客户,
我想从 ATM 取现金,
这样我就不必在银行排队等候。
场景 1:账户有余额
text
Scenario: 账户有余额
Given 账户有余额
And 卡有效
And 出钞机中有现金
When 客户请求取现
Then 确保账户被扣款
And 确保吐出现金
And 确保退回银行卡
场景 2:账户透支且超过透支额度
text
Scenario: 账户透支且超过透支额度
Given 账户已透支
And 卡有效
When 客户请求取现
Then 确保显示拒绝消息
And 确保不吐出现金
And 确保退回银行卡
注意这里的 And:它让多个前置条件、多个结果能自然串起来。同时,两条场景共享了部分片段(比如"卡有效""客户请求取现""退回银行卡"),这为复用打下基础。
让验收标准可执行:把场景拆成片段
到这里为止,你写下的还是"文本"。BDD 的下一步是:把场景拆成更细的、可复用的片段,并让它们能直接对应到代码。
最关键的思想是:Given / When / Then 不是三段文章格式,而是三类可复用的"步骤":
text
Given:把系统放进一个已知状态
When :触发一次行为
Then:验证结果(可观察输出)
实现上你可以把这些步骤组织成代码对象(不拘泥具体语言/框架):
- 准备一个共享的"世界/上下文"(world),用于存放本场景里用到的对象与状态。
- 依次执行 Given,把 world 填成"已知状态"。
- 执行 When,让事件在 world 中发生。
- 执行 Then,对 world 的可观察结果做检查。
这样做有两个工程收益:
- 片段可复用:同一个 Given/Then 可以在很多场景里复用,避免每条场景都从零写一套脚本。
- 从 mock 到真实对象的迁移更平滑:早期你可能用 mock 快速把状态搭出来;随着实现推进,你把这些步骤逐步替换成真实对象与真实交互,最后自然演化成端到端的功能验证。
一句话收口
BDD 的核心不是引入一堆新术语,而是用两种"写作模板"把团队思考固定下来:
- 用
should把代码层的行为说清楚(并在失败时更容易判断 bug/迁移/废弃)。 - 用
Given / When / Then把需求层的行为说清楚(并把验收标准拆成可执行、可复用的片段)。
BDD x OpenSpec:把场景沉淀成可追溯的工程资产
但在真实团队里,还有一个常见断点:场景写出来了,随后就散落在 PRD、会议纪要、聊天记录、测试用例、代码注释里,过一两周就失去"证据链"。
OpenSpec(开源)补的是这一块:它不替代 BDD,而是提供一个把场景/规格/任务/决策放进 Git 并可归档的组织方式,让"行为描述"能长期可维护。
1) 一张对照表:BDD 的写法,落到 OpenSpec 的哪里
| 你在 BDD 里写的东西 | 在 OpenSpec 里落到哪里 | 带来的工程收益 |
|---|---|---|
| 用户故事(作为谁/想要/以便) | proposal.md(Intent/Scope/Approach) |
需求的价值、边界、取舍能被 Review、能追溯 |
| 场景(Given/When/Then) | specs/<domain>/spec.md(Delta Spec,写"本次变更新增/修改/删除的行为") |
验收标准变成可版本化的"行为契约" |
| 场景拆成可复用步骤(Given/When/Then steps) | tasks.md(按步骤拆成可勾选的小任务) |
实现顺序与验收点清晰,便于小步合入/回滚 |
| "这条行为属于谁"的归属判断(should 约束) | design.md(关键决策与职责边界) |
避免决策只存在脑子里;新人能复盘"当时为什么" |
2) 一张图:把"场景"串成一条交付闭环
text
BDD 的场景(Given/When/Then)
-> 变成 Delta Spec(可 Review 的行为契约)
-> 变成 Tasks(可勾选的小步实现)
-> 变成 Code(按任务逐步落地)
-> 归档 Change(proposal/design/tasks/specs 一起进历史)
如果你用过 Cucumber/Gherkin,会发现 BDD 的核心写法是一致的:关键不在"是否用某个框架",而在统一团队的行为叙事方式;OpenSpec 的价值是把这套叙事变成"可进 Git 的资产"。
3) 用本文 ATM 场景举个"落到仓库里"的例子(示意)
你在本文里已经写出了验收场景(Given/When/Then)。把它放进 OpenSpec 的一个 change(示意结构):
text
openspec/
changes/
2026-xx-xx-atm-withdraw/
proposal.md
tasks.md
specs/
atm/
spec.md
然后把"账户有余额"这条场景写进 Delta Spec(示意片段,重点是结构而不是格式细节):
markdown
## ADDED Requirements
### Requirement: ATM 取现
#### Scenario: 账户有余额
- **GIVEN** 账户有余额,卡有效,出钞机中有现金
- **WHEN** 客户请求取现
- **THEN** 账户被扣款,吐出现金,退回银行卡
这样做的一个现实收益是:当线上反馈"余额足但没吐钞"时,你能在 archive/ 里翻到当时的行为口径、任务拆解和关键决策,而不是在聊天记录里考古。
术语表(第一次看到就查这里)
- TDD(测试驱动开发):先写一个会失败的测试,再写最少的实现让它通过,再重构。
- BDD(行为驱动开发):把注意力从"测试"转到"行为",让行为描述(包括需求验收)能被自动验证。
should:测试/行为方法命名的句式约束,逼你明确"当前组件的承诺"。- 验收标准(acceptance criteria):判断需求是否交付的条件集合;在 BDD 里常用场景表示。
- 场景(scenario):一条具体的可验收情境,通常用
Given/When/Then描述。 - 通用语言(ubiquitous language):团队共享的一套领域词汇,尽量从需求一路贯穿到代码命名。
- 依赖注入(dependency injection):把依赖通过构造函数/参数传入,便于替换与测试(例如替换成 mock)。
- mock:测试替身,用来快速搭建状态或隔离外部依赖,先把行为跑通。