需求文档没人看,Cucumber 太重,但你又想在动手写代码之前把事情想清楚。

你有没有遇到过这种情况——

产品经理写了一页需求文档,开发看了两眼就开始写代码。写完发现理解有偏差,返工。下次换成 Jira ticket,列了一堆验收条件,开发对着清单一条一条打勾,功能”验收通过”了,但用起来别扭——因为没有人从头到尾想过”用户到底是怎么用这个东西的”。

BDD 试图解决这个问题:用 Given/When/Then 把行为写出来,直接跑。但 Cucumber 的学习成本、step definition 的维护成本、结构化语法的表达力限制,让很多团队试了又放弃了。

Story-Test-Dev 是一个更轻量的方案。 核心想法很简单:

Story(写故事) → Test(提取测试) → Dev(实现代码)
  1. Story:用自由叙事体写用户故事——有人物、有场景、有动机,像在讲一个人怎么用你的产品
  2. Test:从故事中提取测试用例,用 > 关联测试:TestXxx 锚点把场景和测试关联起来
  3. 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 / CucumberStory-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 文件包含三层:

  1. 背景:这个人是谁,他在什么情境下需要这个功能
  2. 场景:他具体怎么操作,系统怎么响应,每个场景带 > 关联测试: 锚点
  3. 延伸思考(可选):这个功能未来可能怎么演进

写 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.mdtests/test_todo_api.py

什么时候适合用

  • 中小规模项目:Story-Test-Dev 的手动锚点在几十个场景内完全可控
  • API 服务:故事天然适合描述”用户怎么调接口”
  • AI 辅助开发:把 story 喂给 AI,让它生成测试代码,再生成实现代码——story 充当了 AI 的需求文档
  • 团队沟通:故事文件可以直接发给产品经理或新同事看,不需要解释 Given/When/Then 是什么

不太适合的场景:

  • 大规模团队(50+ 人):手动锚点的维护成本会上升,可能需要工具化
  • UI 密集型产品:叙事体更适合描述数据流和 API 交互,复杂的 UI 交互用 Playwright 的 codegen 可能更直接

Footnotes

  1. Markus Gärtner, ATDD by Example, 2012

  2. Gojko Adzic, Specification by Example, 2011

  3. Matt Wynne, Aslak Hellesøy, The Cucumber Book, 2012