﻿---
title: "Story-Test-Dev：叙事驱动开发"
description: "先写故事，再提测试，最后写代码。一种用 Markdown + pytest 就能落地的轻量开发方法，以及它和 BDD、ATDD 等业界实践的异同。"
pubDate: 2026-03-14
tags: ["方法论", "测试", "BDD", "开发流程"]
clc: "TP311.5"
hero: "./hero.svg"
refs:
  - id: "2"
    title: "Specification by Example"
    author: "Gojko Adzic"
    date: "2011"
  - id: "3"
    title: "The Cucumber Book"
    author: "Matt Wynne, Aslak Hellesøy"
    date: "2012"
  - id: "5"
    title: "ATDD by Example"
    author: "Markus Gärtner"
    date: "2012"
  - id: "1"
    title: "Introducing BDD"
    url: "https://dannorth.net/introducing-bdd/"
    collect: "dannorth-introducing-bdd"
  - id: "4"
    title: "BDD in the Large"
    author: "Liz Keogh"
    further: true
---

> 需求文档没人看，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`：

```markdown
# 待办事项 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`：

```python
"""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 结构化语法描述行为，直接作为可执行测试[^1]。代表工具：Cucumber, Behave (Python), SpecFlow。

**ATDD (Acceptance Test-Driven Development)** — Kent Beck, Ward Cunningham。先定义验收标准，再写验收测试，最后实现。参考书：*ATDD by Example*[^5]。

**Specification by Example (SBE)** — Gojko Adzic。用具体实例（而非抽象规格）驱动开发，实例同时充当活文档和测试。参考书：*Specification by Example*[^2]。

**Example Mapping** — Matt Wynne (Cucumber 团队)。用卡片把 Rule → Example → Question 映射出来，再转化为测试。参考书：*The Cucumber Book*[^3]。

这些方法论都在解决同一个问题：**在写代码之前，先把"要做什么"想清楚，并且让这个"想清楚"的产物能自动验证。**

### 那 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 文件包含三层：

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

写 story 的时候，想象你在跟一个不懂技术的同事讲"这个功能是干什么的"。如果你能把它讲成一个连贯的故事，说明你自己已经想清楚了。

### 锚点怎么用

```markdown
> 关联测试：`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 可能更直接

---

## 参考资料
