YiBi Dev Docs
架構決策 (ADR)

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 charsrequire-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 tracetest docstring 指向不存在 Scenario 的 stale reference → scanner 輸出 [WARN] Orphan: <cap>#<slug>--

工具鏈

工具路徑用途
Scannerbackend/scripts/check_spec_coverage.pyCLI:掃 spec + 掃 tests → coverage 表格;支援 --cap 限定範圍
Strict wrapperscripts/spectra-strict.shShell script:執行 scanner(--cap 限定 pilot)後再跑 spectra validate --strict;非 native plugin(spectra v2.3.1 無 plugin 機制)
文件docs/openspec/CLAUDE.md + backend/CLAUDE.mdSlug 規範 + 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-ratchet job (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-status rollup。
  • 不回補存量測試:現有大量 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 佈局、已知限制)不變。

On this page