BDD Spec-Test Traceability -- docstring trace 機制
Context
背景
yibi-mvp 的 openspec 有 59/76 份 spec 包含 #### Scenario: 段落,但後端測試
(backend/tests/)是純 pytest 風格,沒有任何機制連結 spec Scenario 與測試函式:
- 無
.feature檔、無 pytest-bdd、無 step definitions - 無 docstring 引用、無 traceability map
- 沒有工具可以問「account-settings-page 的 6 個 Scenario 是否都有測試覆蓋?」
ADR-0006 五尺度約束的「小(Scenario)」層級已在 spec 端落地,但從 Scenario 到「微(Micro Task)」的可執行測試之間沒有橋樑。
注意:另有 6 份 spec 含「Scenario」文字但使用
**Acceptance Scenarios**:prose 格式(而非#### Scenario:BDD heading),scanner 不解析此格式。59 份是 真正有#### Scenario:heading 的 spec。
問題規模
- 59 份 spec × 平均 18.6 個 Scenario ≈ 1100 個 Scenario,目前 trace 率 0%
spectra validate --strict可以偵測「Requirement 沒有 Scenario」, 但不能偵測「Scenario 沒有測試」
觸發決策
ADR-0006 Postscript(2026-05-19)明確列為後續 ADR-0008。 Change 0018-bdd-spec-test-traceability 的執行前需要先有此 ADR 記錄選型決策。
Decision
結論:採用 docstring trace 模式(Option A),不引入 pytest-bdd 框架。
Option A -- Docstring Trace(採用)
在 spec 的 #### Scenario: heading 加顯式 slug,在 pytest 測試函式 docstring 加
spec: <cap>#<slug> 引用:
#### Scenario: require-current-password -- 必須提供當前密碼def test_password_change_requires_current_password():
"""
spec: account-settings-page#require-current-password
"""搭配 check_spec_coverage.py scanner 掃描覆蓋率,以 shell wrapper 搭配
spectra validate --strict 輸出 WARNING。
為何選 Option A:
- 零新 dependency(pytest-bdd 需要 3 個 pip package + .feature 語法學習成本)
- 不改現有測試風格(pytest 函式保持 Pythonic)
- 漸進採用:現有 ~1100 個 Scenario 可逐批補,不需一次全改
- scanner 是獨立 Python script,無框架耦合,測試容易
Option B -- pytest-bdd(未採用)
引入 .feature 檔 + step definitions。
為何不選:
- 雙重維護:spec Scenario 文字與 .feature Scenario 文字需同步,否則 spec 和程式碼就 drift
- 新手學習成本高:Gherkin 語法 + step binding pattern
- 與現有 pytest 測試混合時 CI 設定複雜
- 替現有 59 份 spec × 18 Scenario 補 .feature 是 big bang migration,風險高
Scenario Slug 規範
| 欄位 | 規則 | 範例 |
|---|---|---|
| anchor 格式 | #### Scenario: <slug> -- <可讀標題> | #### Scenario: require-current-password -- 必須提供當前密碼 |
| slug 規則 | kebab-case,顯式命名(不自動從標題 derive),< 50 chars | require-current-password |
| CJK / 數字開頭 | slug 另行以英文命名,原 CJK 標題保留在 -- 後 | #### Scenario: age-4-story-gen -- 4 歲孩子生成故事 |
| docstring 格式 | spec: <cap>#<slug> 作第一行(無 scenario- prefix) | spec: account-settings-page#require-current-password |
| cap 命名 | spec.md 的直接父目錄名(kebab-case) | account-settings-page, E02-child-profile |
| 巢狀 spec cap | 直接父目錄名(不含上層 Epic 前綴) | E12-device-interactive/F016-daily-task-challenge/spec.md → cap = F016-daily-task-challenge |
| in-spec 唯一性 | 同一 spec 內 slug 必須唯一(scanner 報 ERROR 若重複) | -- |
| orphan trace | test docstring 指向不存在 Scenario 的 stale reference → scanner 輸出 [WARN] Orphan: <cap>#<slug> | -- |
工具鏈
| 工具 | 路徑 | 用途 |
|---|---|---|
| Scanner | backend/scripts/check_spec_coverage.py | CLI:掃 spec + 掃 tests → coverage 表格;支援 --cap 限定範圍 |
| Strict wrapper | scripts/spectra-strict.sh | Shell script:執行 scanner(--cap 限定 pilot)後再跑 spectra validate --strict;非 native plugin(spectra v2.3.1 無 plugin 機制) |
| 文件 | docs/openspec/CLAUDE.md + backend/CLAUDE.md | Slug 規範 + docstring 格式 + cap 命名規則 |
重要:spectra validate --strict <change-name> 驗的是 change 名稱(如 0018-bdd-spec-test-traceability),不接受 spec cap name。
Coverage gate 使用 scanner script 而非 spectra validate 參數。
Warning 洪水防護:scanner --cap 限定 pilot scope,不全局掃描所有 ~1100 個 Scenario。
全局掃描在初期會產生大量 WARNING,違反 DoD 的「--strict 通過才能 PR」規則。
Alternatives Considered
-
Option C:混合模式(熱路徑 pytest-bdd,其他 docstring): 判斷「哪些是熱路徑」本身需要主觀決策,而 yibi-mvp 目前沒有 hotspot 分類。 漸進採用 Option A 更簡單,未來可以對特定 Epic 升級到 Option B。
-
維持現狀(0% trace):Scenario 只是文件,沒有可執行的品質保證, spec 投資無法兌現。不可接受。
-
Auto-derive slug from heading title:slug 從 Scenario 標題自動 kebab-ify。 問題:CJK 標題需要拼音/翻譯決策,數字開頭 slug 不符合識別符規範,改標題會破壞 所有 test docstring 引用(silent orphan)。顯式命名更安全。
Consequences
好:
- spec Scenario 與測試之間有明確的機器可讀連結
check_spec_coverage.py可以直接量化 spec coverage 率- 漸進採用,不需 big bang migration
- scanner 輸出 orphan trace,防止 stale docstring 靜默堆積
壞 / 代償:
- 需要維護 slug 命名規範(若 Scenario 改名,docstring 引用需同步更新)
- scanner 是獨立工具,不是 pytest plugin(沒有 test run 時的 inline 驗證)
- 初期 coverage 率極低(0%),需要一段時間才能建立信心
- 初始批次補 slug 會產生大量 spec.md 編輯;對 active spec 補 slug 是 typo 等級修正, 不需開新 change;但若 Scenario 標題因 slug 規則被改寫,視為 modification 走正常 change 流程
中性:
- 不影響現有 CI pass/fail(scanner 只 WARNING,不 error)
- 現有測試無需立即修改(只在 pilot account-settings-page 補 docstring)
- 未來若需要更嚴格的 traceability,可以加 CI step:
check_spec_coverage.py --exit-on-missing
Postscript — 2026-07-03:採用漸進式 ratchet(deciders: howie,issue #720 Q2 裁決)
依 issue #720 Q2 裁決(decider: howie):採用漸進式 ratchet——traced Scenario 數量只允許上升,不回補存量測試。
裁決前現況:本 ADR 早已 accepted,但 check_spec_coverage.py 未接進任何
CI/pre-commit/Makefile——「accepted 但 inert」。issue #720 審查引述的
「1,643 個 Scenario 僅 11 個測試 docstring 引用(<1%)」為低估;裁決當時
在 main 上實測為 366 條 trace 引用、分布於 63 個測試檔、覆蓋 103 個
Scenario(約佔全部 ~1,643 個 Scenario 的 6.3%)。數字修正不影響結論:
採用率仍低,「accepted 但 inert」的診斷成立。Q2 裁決兩個選項
((a) ratchet 漸進 vs (b) 降級 superseded + 移除 scanner)中選擇 (a)。
落地方式:
check_spec_coverage.py新增--ratchet <baseline-file>模式:比對目前 「至少有一個測試 trace 的 Scenario 數」(compute_coverage().covered長度) 與 checked-in baseline 檔案;只允許上升,下降視為迴歸並 fail loud。- 初始 baseline 存於
backend/scripts/spec_coverage_baseline.txt(落地時 103,見 issue #720 P1-2 PR)。 - CI 接進
.github/workflows/ci.yml的獨立spec-coverage-ratchetjob (ubuntu,純 stdlib Python,無 macOS-only 依賴),path filter 同時涵蓋backend/**與docs/openspec/specs/**——後者確保「只刪改 canonical spec 的 PR」(會 orphan 既有 trace、拉低 covered 數)也無法繞過 ratchet。 指令(working-directory:backend/):python3 scripts/check_spec_coverage.py --ratchet scripts/spec_coverage_baseline.txt, 並納入ci-statusrollup。 - 不回補存量測試:現有大量 missing/orphan Scenario 維持現狀,新 PR 只要
不讓「已建立的 trace」數量倒退即可通過;新增測試若想貢獻 traceability,
需同時在該 PR 內把 baseline 數字調高(scanner 會在 count 上升時印出
echo <new-count> > backend/scripts/spec_coverage_baseline.txt提示)。 - 已知限制(gameability):scanner 是純文字掃描——寫在註解裡或被 skip 的 測試中的 trace 字串同樣計入,捏造一組「假 Scenario + 假 trace」也能灌高 count;ratchet 防的是無意的倒退,不是惡意灌水,後者由 code review 把關。
- 此 postscript 不改變本 ADR 原本的 Option A(docstring trace)選型,只是把 「scanner 存在但未接線」的缺口用 ratchet 補上,讓 CI 真正開始把關 traceability 不再倒退。
2026-07-03 補充裁決:hard ratchet(owner: howie,issue #720 Q2 follow-up)
同日 follow-up 裁決將 ratchet 從 soft floor 升級為 hard ratchet(monotonic high-water mark)。執行的不變量:baseline 檔案必須永遠等於目前的 covered 數——covered 數的任何變動都要求在同一個 PR 內同步更新 baseline 檔案:
- count 下降:直接 fail(同原裁決,視為迴歸,rejected outright)。
- count 上升但 baseline 未同步調高:也 fail——訊息會印出確切指令
(
echo <new-count> > scripts/spec_coverage_baseline.txt,自backend/目錄執行)要求在同一個 PR 內鎖定漲幅。理由:soft floor 下漲幅不會被鎖定 (103 升到 113 但 baseline 停在 103,之後回落到 104 仍然通過),高水位 形同虛設。 - count 等於 baseline:通過。
上文落地方式中「scanner 會在 count 上升時印出提示」的行為自此升級為 強制:印出提示且 exit 1。本補充僅收緊 rise 分支的 enforcement, 其餘裁決內容(不回補存量、baseline 檔案位置、CI job 佈局、已知限制)不變。