需求文档没人看,Cucumber 太重,但你又想在动手写代码之前把事情想清楚。
你有没有遇到过这种情况——
产品经理写了一页需求文档,开发看了两眼就开始写代码。写完发现理解有偏差,返工。下次换成 Jira ticket,列了一堆验收条件,开发对着清单一条一条打勾,功能”验收通过”了,但用起来别扭——因为没有人从头到尾想过”用户到底是怎么用这个东西的”。
BDD 试图解决这个问题:用 Given/When/Then 把行为写出来,直接跑。但 Cucumber 的学习成本、step definition 的维护成本、结构化语法的表达力限制,让很多团队试了又放弃了。
Story-Test-Dev 是一个更轻量的方案。 核心想法很简单:
Story(写故事) → Test(提取测试) → Dev(实现代码)
- Story:用自由叙事体写用户故事——有人物、有场景、有动机,像在讲一个人怎么用你的产品
- Test:从故事中提取测试用例,用
> 关联测试:TestXxx锚点把场景和测试关联起来 - Dev:实现代码,让测试通过
不需要安装框架,不需要学 DSL。Markdown + pytest,完事。
Part 1: WHAT — 一个完整的例子
空谈方法论没有用。我们用一个”待办事项 API”从头走一遍。
写 Story
创建 stories/todo_api.md:
# 待办事项 API
## 背景
小王是一个前端工程师,他需要一个简单的待办事项后端 API。
他不想用重量级的项目管理工具,只想要一个能增删查的 REST 接口,
数据存本地就好。
---
## 小王的日常使用
### 场景 1:创建一条待办
> 关联测试:`TestTodoAPI.test_create_todo`
小王打开终端,向 `POST /api/todos` 发送了一条请求,
body 里写着 `{"title": "买咖啡", "due": "2025-03-15"}`。
接口返回 201,response 里带着刚创建的待办,
有 `id`、`title`、`due`、`done: false` 四个字段。
一条待办就建好了。
### 场景 2:查看所有待办
> 关联测试:`TestTodoAPI.test_list_todos`
小王请求 `GET /api/todos`,返回一个列表,
里面有他刚才创建的那条。他又加了一条 "写周报",
再次请求,列表里变成了两条,按创建时间排序。
### 场景 3:完成一条待办
> 关联测试:`TestTodoAPI.test_complete_todo`
小王买完咖啡回来,向 `PATCH /api/todos/{id}` 发送
`{"done": true}`。接口返回更新后的待办,`done` 变成了 `true`。
他再看列表,那条待办还在,只是状态变了——
他不想完成就消失,万一要回顾呢。
注意几个特点:
- 人物是具体的:“小王,前端工程师”——不是抽象的”用户”
- 场景是叙事的:像在讲一个人的使用过程,不是在列需求清单
- 锚点是显式的:每个场景都有
> 关联测试:标注对应的测试方法 - API 细节嵌在故事里:请求路径、参数、返回值都在叙事中自然出现
从 Story 提取 Test
创建 tests/test_todo_api.py:
"""Tests for Todo API — derived from stories/todo_api.md"""
import pytest
import requests
class TestTodoAPI:
"""Test cases mapped to story scenarios."""
def test_create_todo(self):
"""场景 1:创建一条待办"""
response = requests.post(
f"{BASE_URL}/api/todos",
json={"title": "买咖啡", "due": "2025-03-15"},
)
assert response.status_code == 201
data = response.json()
assert data["title"] == "买咖啡"
assert data["due"] == "2025-03-15"
assert data["done"] is False
assert "id" in data
def test_list_todos(self):
"""场景 2:查看所有待办"""
# 先创建两条
requests.post(f"{BASE_URL}/api/todos", json={"title": "买咖啡"})
requests.post(f"{BASE_URL}/api/todos", json={"title": "写周报"})
response = requests.get(f"{BASE_URL}/api/todos")
assert response.status_code == 200
todos = response.json()
assert len(todos) == 2
def test_complete_todo(self):
"""场景 3:完成一条待办"""
# 创建
created = requests.post(
f"{BASE_URL}/api/todos",
json={"title": "买咖啡"},
).json()
# 完成
response = requests.patch(
f"{BASE_URL}/api/todos/{created['id']}",
json={"done": True},
)
assert response.status_code == 200
assert response.json()["done"] is True
# 完成后仍在列表中
todos = requests.get(f"{BASE_URL}/api/todos").json()
completed = [t for t in todos if t["id"] == created["id"]]
assert len(completed) == 1
assert completed[0]["done"] is True
测试用例和 story 场景一一对应。测试方法名就是锚点里写的名字,docstring 标注了对应的场景编号。
实现代码
测试写好了,接下来写实现让它们通过。这一步就是常规的开发——重点是:story 和 test 在你动手写代码之前就已经存在了。
这就是 Story-Test-Dev 的全部流程。没有框架要装,没有 DSL 要学,没有 step definition 要维护。
Part 2: WHY — 为什么不直接用 BDD
Story-Test-Dev 的灵感来自几个成熟的方法论。但它刻意做了减法。
业界有什么
BDD (Behavior-Driven Development) — Dan North, 2006。用 Given/When/Then 结构化语法描述行为,直接作为可执行测试。代表工具:Cucumber, Behave (Python), SpecFlow。
ATDD (Acceptance Test-Driven Development) — Kent Beck, Ward Cunningham。先定义验收标准,再写验收测试,最后实现。参考书:ATDD by Example1。
Specification by Example (SBE) — Gojko Adzic。用具体实例(而非抽象规格)驱动开发,实例同时充当活文档和测试。参考书:Specification by Example2。
Example Mapping — Matt Wynne (Cucumber 团队)。用卡片把 Rule → Example → Question 映射出来,再转化为测试。参考书:The Cucumber Book3。
这些方法论都在解决同一个问题:在写代码之前,先把”要做什么”想清楚,并且让这个”想清楚”的产物能自动验证。
那 Story-Test-Dev 做了什么不同的事
| 维度 | BDD / Cucumber | Story-Test-Dev |
|---|---|---|
| Story 格式 | Given/When/Then 结构化语法 | 自由叙事体(“小王打开终端……”) |
| Story 与测试的关系 | 1:1 自动绑定(框架驱动) | 手动锚点 > 关联测试:TestXxx |
| Story 的读者 | 主要是开发和 QA | 更像产品文档,任何人可读 |
| 工具依赖 | 需要 Cucumber 等专用框架 | 纯 Markdown + pytest,零框架依赖 |
| 叙事深度 | 面向机器解析,偏干 | 面向人类阅读,有背景、动机、感受 |
展开说三个关键差异。
自由叙事 vs 结构化语法。 BDD 的 Given/When/Then 是为了让机器解析而设计的,写出来像填表格。Story-Test-Dev 的故事是给人读的——有背景介绍、有人物动机、有使用感受。“小王买完咖啡回来,向 PATCH /api/todos/{id} 发送……” 比 “Given a completed todo, When the user patches it” 多传达了一层信息:这是一个自然的使用场景,不是在填验收清单。
手动锚点 vs 自动绑定。 BDD 框架要求 step definition 和 feature file 通过正则匹配绑定。Story-Test-Dev 只用一行 Markdown 引用就够了。好处是零工具依赖;代价是如果测试改名了,锚点需要手动更新。对于中小规模项目,这个取舍是值得的。
零框架依赖。 不需要学 Cucumber,不需要写 step definition,不需要配置 runner。写 Markdown,写 pytest,完了。团队里来了新人,给他看 story 文件他就能理解功能,给他看 test 文件他就能理解接口。
Part 3: HOW — 落地要点
Story 怎么写
一个好的 story 文件包含三层:
- 背景:这个人是谁,他在什么情境下需要这个功能
- 场景:他具体怎么操作,系统怎么响应,每个场景带
> 关联测试:锚点 - 延伸思考(可选):这个功能未来可能怎么演进
写 story 的时候,想象你在跟一个不懂技术的同事讲”这个功能是干什么的”。如果你能把它讲成一个连贯的故事,说明你自己已经想清楚了。
锚点怎么用
> 关联测试:`TestClassName.test_method_name`
这行 Markdown 就是 story 和 test 之间的全部绑定机制。规则很简单:
- 锚点里的名字和测试方法的全限定名一致
- 一个场景对应一个测试方法
- 测试文件的 docstring 里标注对应的场景编号,方便反向查找
没有自动化检查。如果你想加一个 CI 步骤来检查锚点是否和测试名匹配,写个脚本扫描 stories/*.md 里的锚点、再对照 tests/ 里的方法名就行——几十行 Python 的事。但很多时候,手动保持一致就够了。
项目结构
my_project/
├── stories/ # 用户故事
│ ├── todo_api.md
│ └── user_auth.md
├── tests/
│ ├── test_todo_api.py # 从 story 提取的测试
│ └── test_user_auth.py
├── src/
│ └── ... # 实现代码
└── story-test-dev.md # 方法说明(可选)
Story 文件和测试文件的命名保持对应关系:stories/todo_api.md → tests/test_todo_api.py。
什么时候适合用
- 中小规模项目:Story-Test-Dev 的手动锚点在几十个场景内完全可控
- API 服务:故事天然适合描述”用户怎么调接口”
- AI 辅助开发:把 story 喂给 AI,让它生成测试代码,再生成实现代码——story 充当了 AI 的需求文档
- 团队沟通:故事文件可以直接发给产品经理或新同事看,不需要解释 Given/When/Then 是什么
不太适合的场景:
- 大规模团队(50+ 人):手动锚点的维护成本会上升,可能需要工具化
- UI 密集型产品:叙事体更适合描述数据流和 API 交互,复杂的 UI 交互用 Playwright 的 codegen 可能更直接