Status: Implemented(src/generate.rs / src/traceability.rs)。本資料は元々UC2の実現方法を事前検討した設計ドラフトだったが、実装(src/generate.rs)は細部で本資料の初期案と異なる形に落ち着いた。本版は実装に合わせて全面的に書き直したものであり、初期案からの差分は各節末の「実装時の変更」に残す。
関連ドキュメント: テスト知識管理のGit-nativeモデル_統合版.md(以下「論文」)、product-operation.md
位置づけ:本資料は論文および「プロダクト運用イメージ」(docs/product-operation.md)を踏まえ、UC2「TestCaseを決定的生成する」の具体的な実現方法を記述したものです。論文本文に明記されている箇所には該当節番号を付し、製品化にあたって補った箇所は「(製品化提案、論文本文には明記なし)」と明記します。
論文はFEATURE/CONDITION→TESTCASEの生成関係を「構造的な生成グラフ」(静的、版に依存しない)と呼び、研究の核心的貢献(RQ1、版履歴DAG)とは切り分けています(§3.2(A))。一方で、この生成グラフ自体はツール構成として設計対象に含まれており(§4.5「テストケース生成ツール:Feature + ConditionからTestCaseを生成し、再生成結果と現在のファイルの一致をCIで検証」)、docs/product-operation.mdではUC2・UC3として運用フロー上に組み込まれています。
UC1(知識を記述する) --include--> UC2(TestCaseを決定的生成する) --include--> UC3(生成物をレビュー・マージする)
しかし、UC2の記述(docs/product-operation.md 105行目)は「Feature+Conditionの組を機械的に走査しgenerated/testcases/*.ymlを再生成」とあるのみで、走査方法・命名規則・テキスト組み立て規則・決定性の担保方法は未定義です。本資料はこの空白を埋めます。
スコープ外であることの確認:論文§7は「構造からのテストケース自動生成の網羅率評価」を将来課題としていますが、これは生成されたTestCase集合が実際のテスト観点をどれだけ網羅できているかという評価の話であり、本資料が扱う「決定的に生成する方式そのものの設計」とは別軸です。本資料は後者のみを対象とし、網羅率評価には立ち入りません。同様に、LLMによる生成拡張(付録A.1)もスコープ外です。
実装(src/knowledge.rs)における実際のファイル構成は以下の通りです(markharness initが作る規約に基づく、拡張子は.yml)。
knowledge/<requirement>/
├── requirement.yml # id, label, axis, description?
└── <feature>/
├── feature.yml # id, requirement, label, axis, description?, forked_from?
└── <behavior>/ # ディレクトリ名は自由。behavior.ymlの有無で判定
├── behavior.yml # id, feature, label, axis, description, preconditions([ADR 0016](/docs/ja/decisions/0016-behavior-condition-precondition-step-result-model.html)。旧steps)
└── <condition>/ # ディレクトリ名は自由。condition.ymlの有無で判定
├── condition.yml # id, behavior, label, description, steps, additional_preconditions(ADR 0016で新設)
└── expected/
├── 001.yml # id, condition, description, results(ADR 0016で新設), additional_steps?, implementation_note?
└── 002.yml
各YAMLのidはGitのblob SHAではなく人間可読なslugです(論文§3.1・§3.5でいう表示用ではなく識別子そのものとして使用)。当初案(以下§9参照)とは異なり、実装ではBehavior/Condition/ExpectedResultのいずれも親要素へのID参照フィールド(feature/behavior/condition)を明示的に持つ(knowledge.rsの各構造体定義)。生成アルゴリズム自体はディレクトリの入れ子構造だけを辿り、これら参照フィールドの値を生成ロジックの分岐には使わない(TestCase.generated_fromへコピーするのみ)が、値の整合性チェック(親参照が実在するか)はmarkharness knowledge reconcile側(decisions/0027)が担う。
論文§3.5は、版履歴計算(id解決キャッシュ、§3.3)におけるidがパスに依存しない設計であるべきことを述べています。これはリネーム耐性のための制約であり、「あるコミット時点でidがどのパスにあったか」をキャッシュで引く際にパス文字列そのものをキーにしないという話です(実装はFeatureのidについてこの原則に従っており、id_cache.rsはディレクトリ名ではなくfeature.ymlのid:フィールドを正準ソースとする。詳細は論文§3.3の実装注記を参照)。
これに対し、テストケース生成は版履歴を必要としない静的処理(§3.2(A))であり、「現在のワーキングツリー上のディレクトリ階層」を入力として一度きり走査します。つまり、
id:フィールド + id解決キャッシュ)という区別が成り立ち、両者は矛盾しません。したがって、本設計ではディレクトリ階層ベースの走査を採用します(§3参照)。
src/generate.rs::generate_testcases)function generate_testcases(knowledge_root):
testcases = []
for requirement_dir in sorted_subdirs(knowledge_root):
if !(requirement_dir / "requirement.yml").is_file(): continue
requirement = parse(requirement_dir / "requirement.yml")
for feature_dir in sorted_subdirs(requirement_dir):
if !(feature_dir / "feature.yml").is_file(): continue
feature = parse(feature_dir / "feature.yml")
for behavior_dir in find_dirs_with_marker(feature_dir, "behavior.yml"):
behavior = parse(behavior_dir / "behavior.yml")
for condition_dir in find_dirs_with_marker(behavior_dir, "condition.yml"):
condition = parse(condition_dir / "condition.yml")
expected_paths = sorted(list_files(condition_dir / "expected"))
if expected_paths.is_empty(): continue # Conditionのみでは生成されない(§6)
expected_results = [parse(p) for p in expected_paths]
phases = []
for i, e in enumerate(expected_results):
additional_steps = e.additional_steps or []
steps = (condition.steps + additional_steps) if i == 0 else additional_steps
phases.append(Phase{steps: steps, results: e.results})
testcases.append(TestCase{
case_id: f"tc-{requirement.id}-{feature.id}-{behavior.id}-{condition.id}",
generated_from: {requirement.id, feature.id, behavior.id, condition.id,
expected_results: [e.id for e in expected_results]},
preconditions: behavior.preconditions + condition.additional_preconditions,
phases: phases,
axis: union_sorted_dedup(requirement.axis, feature.axis, behavior.axis), # 3.4節
})
return sorted(testcases, key=lambda tc: tc.case_id)
sorted_subdirsは各階層でディレクトリ名を文字列ソートしてから走査するため、ファイルシステムの列挙順に依存しない。find_dirs_with_marker(root, marker_file)はroot配下を再帰的に探索し、marker_file(behavior.yml/condition.yml)を直接含むディレクトリを見つけ次第、そのブランチの探索を打ち切って結果に加える。したがってbehavior/conditionディレクトリはfeature/behaviorの直下である必要はなく、中間ディレクトリを何段挟んでもよい(中間ディレクトリ自体に意味は無い、Feature/Behavior/Conditionの厳密な直下配置を要求しない)。expected/が存在しない、または空の場合はそのConditionからはTestCaseを生成しない(3.2節、当初案とは異なり「Condition+ExpectedResultが揃って初めて1件」という単位)。当初案(§9参照)とは異なり、TestCaseは「1 Condition = 1 TestCase」の単位で生成され、そのConditionが持つ全てのExpectedResultを1つのTestCaseに集約する(ExpectedResultの数だけTestCaseを分割しない)。case_idは
tc-{requirement.id}-{feature.id}-{behavior.id}-{condition.id}
で命名する(旧版はtc-{condition.id}-001という、末尾に予約の連番3桁を付ける命名だったが、condition.idだけを別のBehavior配下で再利用するとcase_idが衝突する欠陥があったため、requirement/feature/behavior/conditionの4つのidをすべて連結する形に変更した。knowledge/のディレクトリ階層自体が同じcondition.idの重複を許さない構造になっているため、その階層をそのままcase_idに反映させれば、衝突を検出する別レイヤーの検査を持たなくても衝突が構造的に起こり得なくなる)。generated/testcases/配下の出力先も同様に、case_idと同じ理由でcondition.id単体のフラットなファイル名(generated/testcases/ground.yml)から、knowledge/と同じ階層をそのままミラーしたgenerated/testcases/{requirement.id}/{feature.id}/{behavior.id}/{condition.id}.yml(TestCase::relative_path())に変更した。
当初案が想定していた「固定テンプレート+knowledge側の短い名詞句の埋め込み」による自然文生成(title = "{要約} (#{seq})"等)は採用せず、実装はknowledge側のフィールド値をそのまま転記する方式にした。この方針自体はADR 0016後も変わっていない。
preconditions = behavior.preconditions + condition.additional_preconditions # 連結、加工なし
phases = [Phase{steps: (condition.steps + (e.additional_steps or [])) if i == 0 else (e.additional_steps or []),
results: e.results}
for i, e in enumerate(expected_results)] # expected_resultsはファイル名順
理由:
knowledge/側の文言だけに依存する最も単純な純粋関数になり、決定性(4章)の証明・実装が容易になる。condition.steps/expected_result.resultsの各要素側の書き方の問題として、Test Designerの記述時の責務に寄せた。ADR 0015→ADR 0016への変遷: ADR 0015 Phase 1は、behavior.ymlにdescription(人間向けの1文要約、生成には使わない)とsteps: Vec<String>(順序付きの操作手順、1要素=1操作、TestCase.stepsへそのまま転記)を別フィールドとして持たせ、同一Behavior配下の全Conditionがこのstepsを共有するモデルを採った。実運用でこのモデルを使うと、条件ごとに操作内容が異なる(例:「空白のみのテキストを入力する」と「有効なテキストを入力する」)ケースを表現できない問題が判明し、ADR 0016でこれを置き換えた。ADR 0016では、behavior.steps(内部的にはBehavior.preconditions)の意味を「全Conditionに共通する前提」に変更し、実際の操作手順は新設のCondition.stepsが担う。あわせてExpectedResultにresults: Vec<String>(観測可能な複数の結果)とadditional_steps: Option<Vec<String>>(その結果を確認する前に必要な追加操作)を新設し、TestCaseのtitle/steps/expectedという3つのフラットフィールドをpreconditions/phases: Vec<Phase>に置き換えた。phasesはexpected/*.ymlをファイル名順に走査したPhase{steps, results}の配列で、先頭のphaseだけcondition.stepsをstepsの先頭に置く。
REQUIREMENT・FEATURE・BEHAVIORそれぞれのaxisフィールド(§3.1、axes/*.ymlでレジストリ管理)を合成(union)し、重複除去のうえソートしたものを生成されたTestCase.axisとする(generate.rs::union_axis)。当初案の「Featureのaxisのみ継承」から、3階層分の合成に変更した。これによりgenerated/traceability-index.json(src/traceability.rs、§3.5のディレクトリ構造)側で「観点(Axis)ごとのTestCase一覧」を再構築でき、横断的観点をFeature側だけでなくTestCase側からも引けるようにする(製品化提案、論文本文には明記なし)。traceability-index.jsonはTraceabilityEntry{case_id, requirement, feature, behavior, condition, expected_results, axis}の配列を持つ実装済みの形式であり、当初案の時点では中身が未定義だった。
論文§4.5は「再生成結果と現在のファイルの一致をCIで検証」する前提を置いています。この検証が成立するためには、同一のknowledge/内容から常に同一のgenerated/testcases/*.yml(およびgenerated/traceability-index.json)が得られることが必要です。実装では以下によって決定性を担保しています。
sorted_subdirs・find_dirs_with_marker・expected/配下のファイル列挙のいずれも、文字列ソート(パス昇順)してから処理する(3.1節)。case_idはcondition.idから機械的に導出し(3.2節)、乱数・タイムスタンプ・実行環境依存の値を一切使わない。knowledge/側のフィールド値をそのまま転記するのみで、テンプレート合成・外部呼び出し(LLM等)を含まない。generate_testcases()の戻り値はcase_idで最終ソートしてから返す(generate.rs末尾のtestcases.sort_by)。generated/testcases/書き込み前に既存ディレクトリを丸ごと削除してから再生成する(cli.rsのCommand::Generate)ため、削除済みFeature/Conditionのファイルが残留することもない。これにより、generate_testcases()は同じワーキングツリーに対して冪等であり、markharness verify(1.6節、docs/cli-manual.md)が「CIで再生成 → 既存のgenerated/testcases/*.ymlとバイト単位で比較 → 一致すればOK、不一致なら差分をレビュー要求」というUC2/UC3のフローを実現します。
docs/product-operation.mdのシーケンス図のフォーマットに合わせると、以下のようになります。
sequenceDiagram
actor TD as Test Designer
participant KN as knowledge/**
participant CI as CI Bot
participant GENTMP as 一時生成物(CI内メモリ/tmp)
participant GEN as generated/testcases/*.yml
actor RV as Reviewer
TD->>KN: feature.yml / condition.yml / expected/*.yml を記述・変更
TD->>CI: PRを作成
CI->>KN: knowledge/ を走査(§3.1のアルゴリズム)
CI->>GENTMP: TestCaseを決定的に再生成
CI->>CI: GENTMP と GEN(コミット済み)を比較(markharness verify)
alt 差分あり
CI-->>RV: 差分レビューを要求(UC3、人間の判断ポイント)
RV->>GEN: 意図した変更であればマージ承認
else 差分なし
CI-->>TD: 検証OK(そのままマージ可能)
end
この図は既存のdocs/product-operation.mdの1章シーケンス図における「CI-»GEN: Feature+Conditionから TestCase を決定的に再生成」のステップ(24〜30行目)を、本資料3〜4章のアルゴリズムで具体化したものです。
| ケース | 扱い |
|---|---|
| 1つのFeature(Behavior)に複数のConditionがある | Condition数だけTestCaseが生成される(組み合わせはFeature × Conditionの直積ではなく、実在するConditionのみを列挙するため、実務上の組み合わせ爆発は起きにくい)。 |
| 1つのConditionに複数のExpectedResultがある | TestCaseは1件のまま、TestCase.phases(配列)に、expected/*.ymlをファイル名順に走査したPhase{steps, results}が集約される(ADR 0016)。§3.2の当初案(ExpectedResultごとにTestCaseを分割)からの変更点。 |
ConditionはあるがExpectedResultが無い(expected/が空、または存在しない) |
TestCaseは生成されない(generate.rsが空チェックでスキップする)。 |
| Conditionを持たないFeature/Behaviorがある | TestCaseは生成されない(§3.1のER図でgeneratesの起点はFEATUREとCONDITIONの両方であるため)。 |
forked_fromを持つFeature |
生成アルゴリズムには影響しない。forked_fromは概念的派生を示す手動記述(§3.1)であり、構造的生成グラフ(§3.2(A))とは独立した情報のため、生成ロジックはこのフィールドを参照しない。 |
| Behavior階層の扱い | 当初案とは異なり、実装はbehavior.ymlの存在をConditionと同格の必須階層として扱う(find_dirs_with_markerで明示的に探索し、behavior.preconditions(旧behavior.steps)をTestCase.preconditionsの一部に、behavior.axisをTestCase.axisの合成元に使う。behavior.descriptionはADR 0015 Phase 1以降、生成には使わない人間向け要約)。Behaviorを持たないConditionからTestCaseは生成されない。 |
論文§2.3は、CTMを「分類木からのテストケース生成という点で発想を共有するが、Git管理・バージョン履歴・実行結果追跡を含むライフサイクル管理は範囲外」と位置づけています。本設計もこの立場を踏襲し、新しいテスト設計技法を提案するものではなく、Test Designerが既にknowledge/に記述した設計(Feature/Condition/ExpectedResult)を機械的・決定的にTestCaseへ変換する処理であることを明確にします。テスト観点の網羅性・分類軸の設計自体はTest Designerの責務のままです。
以下は本資料のスコープ外であり、論文§7・付録A.1に委ねます。
TestCase集合の網羅率評価(論文§7)。generate.rsの単体テストgenerates_single_testcase_aggregating_all_expected_files_under_one_conditionが使うフィクスチャで3.1節のアルゴリズムを手動でトレースすると、以下の通り実装の出力と一致します。
requirement.yml: id: req-todo, axis: [security]feature.yml(req-todo/todo/配下): id: todo, axis: [ui, data]behavior.yml(todo/todo-add-task/配下): id: todo-add-task, axis: [ui], description: "User adds a task.", preconditions: ["Click the title field.", "Press the add button."]condition.yml(todo-add-task/todo-add-task-empty-input/配下): id: todo-add-task-empty-input, description: "Title is empty.", steps: ["Do it."], additional_preconditions: []expected/001.yml(1件のみ): id: todo-add-task-empty-input-001, description: "Shows a validation error.", results: ["Shows a validation error."]→ 生成されるTestCase(generated/testcases/todo-add-task-empty-input.yml):
case_id: tc-todo-add-task-empty-input-001
generated_from:
requirement: req-todo
feature: todo
behavior: todo-add-task
condition: todo-add-task-empty-input
expected_results:
- todo-add-task-empty-input-001
preconditions:
- "Click the title field."
- "Press the add button."
phases:
- steps:
- "Do it."
results:
- "Shows a validation error."
axis: [data, security, ui] # requirement[security] + feature[ui, data] + behavior[ui] の合成・重複除去・ソート
本資料の初版(検討ドラフト)はsamples/repo/knowledge/player/**という当時のサンプルデータをもとに、TestCaseをExpectedResultの数だけ分割し{feature_id}-{condition_id}-{連番}というidを振る案を検討していたが、実装(src/generate.rs)は以下の点で異なる設計に決着した。
| 項目 | 初版の案 | 実装 |
|---|---|---|
| TestCaseの生成単位 | ExpectedResult 1件につき1 TestCase | Condition 1件につき1 TestCase(ExpectedResultはexpected配列に集約) |
case_idの形式 |
{feature_id}-{condition_id}-{連番3桁} |
tc-{requirement.id}-{feature.id}-{behavior.id}-{condition.id}(§10参照。旧実装はtc-{condition.id}-001だったが、condition.idがBehaviorをまたいで衝突する欠陥のため変更した) |
| axisの継承元 | Featureのaxisのみ |
Requirement・Feature・Behaviorのaxisを合成(union) |
| preconditions/phasesのテキスト | 固定テンプレートによる文合成 | knowledge側のフィールド値をそのまま転記(加工なし) |
| Behavior階層 | 生成に使わない(将来拡張の余地として言及のみ) | find_dirs_with_markerで明示的に探索し、steps/axisに反映する必須階層 |
| ファイル拡張子 | .yaml(サンプルに合わせた表記) |
.yml(markharness initの規約) |
この変更は、3.2節で述べた通り「1 Condition = 1 TestCase」という単純な対応関係の方が決定性の証明・実装が容易であり、かつCondition自体が既に「1つの検証観点」を表す粒度であるため、ExpectedResultで細分化する必要が薄いと判断されたことによる。
なお、case_idがcondition.idのみから機械的に決まる方式(tc-{condition.id}-001)は、当初案が課題としていた「condition_idがfeature_idのprefixを部分的に含む場合の重複」問題は回避できていたが、別のBehavior配下で同じcondition.idが再利用された場合にcase_idが衝突するという欠陥があった。これは実運用(AIエージェントによる知識生成)で実際に踏まれ、generated/testcases/のフラットな出力先(condition.idのみをファイル名に使う)により生成ファイルが無言で上書きされる事故につながった。この欠陥への対処は§10を参照。
case_id衝突の構造的解消(実運用フィードバックによる変更)§9の実装(tc-{condition.id}-001 + フラットなgenerated/testcases/{condition.id}.yml)を実際にAIエージェントによる知識生成タスクで使わせたところ、異なるBehavior配下で同じcondition.id(例: valid-title)を再利用した際に、後からgenerateしたTestCaseのファイルが先に生成した同名ファイルを無言で上書きし、テストケースが消失する事故が発生した。knowledge apply/knowledge validateの一意性チェックはBehaviorスコープに閉じており、この種のグローバルな衝突は検出しない。
これに対して、「グローバル重複チェックを新設する」のではなく、衝突が構造的に起こり得ない設計に変更する方針を採った。
generated/testcases/の出力先を、knowledge/と同じ4階層({requirement.id}/{feature.id}/{behavior.id}/{condition.id}.yml)でフルミラーする(TestCase::relative_path())。knowledge/のディレクトリ階層自体が同一パスへの重複配置を許さない(同じディレクトリに同じ名前の子ディレクトリを2つ作ることはファイルシステム上できない)ため、これをそのまま出力先に反映すれば、ファイル名の衝突はこの4階層の名前が完全に一致する場合(=つまりそもそもknowledge/側で同一の対象を指している場合)に限られ、構造的に起こり得なくなる。case_idも同様の理由でtc-{requirement.id}-{feature.id}-{behavior.id}-{condition.id}に変更し、execution recordが使うcase_idルックアップキー自体の衝突も同時に解消する(ファイル名だけ直しても、case_idがcondition.idのみに依存したままでは実行結果記録時の曖昧さが残るため)。requirement.id/feature.id/behavior.idもパスの一部になるため、condition.idだけに課していた「有効なslugであること」の検証(is_valid_slugによるパストラバーサル対策)を、この3つのidにも同様に課すようgenerate_testcasesを拡張した。この方針により、「衝突を検出して警告/エラーにする」という別レイヤーの検査ロジックを持つ必要がなくなり、バグのクラス自体を設計から排除できた。後方互換性は考慮していない(破壊的変更)。詳細はchecklist-cli-usability-improvements.mdを参照。