markharness

テストケース自動生成の実現方法:設計ドキュメント

Status: Implemented(src/generate.rs / src/traceability.rs)。本資料は元々UC2の実現方法を事前検討した設計ドラフトだったが、実装(src/generate.rs)は細部で本資料の初期案と異なる形に落ち着いた。本版は実装に合わせて全面的に書き直したものであり、初期案からの差分は各節末の「実装時の変更」に残す。 関連ドキュメント: テスト知識管理のGit-nativeモデル_統合版.md(以下「論文」)、product-operation.md

位置づけ:本資料は論文および「プロダクト運用イメージ」(docs/product-operation.md)を踏まえ、UC2「TestCaseを決定的生成する」の具体的な実現方法を記述したものです。論文本文に明記されている箇所には該当節番号を付し、製品化にあたって補った箇所は「(製品化提案、論文本文には明記なし)」と明記します。


1. 位置づけ・目的

論文はFEATURE/CONDITIONTESTCASEの生成関係を「構造的な生成グラフ」(静的、版に依存しない)と呼び、研究の核心的貢献(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)もスコープ外です。


2. 入力データモデルの確認

実装(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.5は、版履歴計算(id解決キャッシュ、§3.3)におけるidがパスに依存しない設計であるべきことを述べています。これはリネーム耐性のための制約であり、「あるコミット時点でidがどのパスにあったか」をキャッシュで引く際にパス文字列そのものをキーにしないという話です(実装はFeatureのidについてこの原則に従っており、id_cache.rsはディレクトリ名ではなくfeature.ymlid:フィールドを正準ソースとする。詳細は論文§3.3の実装注記を参照)。

これに対し、テストケース生成は版履歴を必要としない静的処理(§3.2(A))であり、「現在のワーキングツリー上のディレクトリ階層」を入力として一度きり走査します。つまり、

という区別が成り立ち、両者は矛盾しません。したがって、本設計ではディレクトリ階層ベースの走査を採用します(§3参照)。


3. 生成アルゴリズム(ディレクトリ階層ベース、src/generate.rs::generate_testcases)

3.1 走査手順(実装の要約)

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)

3.2 TestCase生成単位とid命名規則

当初案(§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())に変更した。

3.3 preconditions / phases のテキスト組み立て

当初案が想定していた「固定テンプレート+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はファイル名順

理由:

ADR 0015ADR 0016への変遷: ADR 0015 Phase 1は、behavior.ymldescription(人間向けの1文要約、生成には使わない)とsteps: Vec<String>(順序付きの操作手順、1要素=1操作、TestCase.stepsへそのまま転記)を別フィールドとして持たせ、同一Behavior配下の全Conditionがこのstepsを共有するモデルを採った。実運用でこのモデルを使うと、条件ごとに操作内容が異なる(例:「空白のみのテキストを入力する」と「有効なテキストを入力する」)ケースを表現できない問題が判明し、ADR 0016でこれを置き換えた。ADR 0016では、behavior.steps(内部的にはBehavior.preconditions)の意味を「全Conditionに共通する前提」に変更し、実際の操作手順は新設のCondition.stepsが担う。あわせてExpectedResultresults: Vec<String>(観測可能な複数の結果)とadditional_steps: Option<Vec<String>>(その結果を確認する前に必要な追加操作)を新設し、TestCasetitle/steps/expectedという3つのフラットフィールドをpreconditions/phases: Vec<Phase>に置き換えた。phasesexpected/*.ymlをファイル名順に走査したPhase{steps, results}の配列で、先頭のphaseだけcondition.stepsstepsの先頭に置く。

3.4 axisの継承

REQUIREMENTFEATUREBEHAVIORそれぞれの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.jsonTraceabilityEntry{case_id, requirement, feature, behavior, condition, expected_results, axis}の配列を持つ実装済みの形式であり、当初案の時点では中身が未定義だった。


4. 決定性の担保

論文§4.5は「再生成結果と現在のファイルの一致をCIで検証」する前提を置いています。この検証が成立するためには、同一のknowledge/内容から常に同一のgenerated/testcases/*.yml(およびgenerated/traceability-index.json)が得られることが必要です。実装では以下によって決定性を担保しています。

  1. 走査順の固定sorted_subdirsfind_dirs_with_markerexpected/配下のファイル列挙のいずれも、文字列ソート(パス昇順)してから処理する(3.1節)。
  2. id生成の固定case_idcondition.idから機械的に導出し(3.2節)、乱数・タイムスタンプ・実行環境依存の値を一切使わない。
  3. テキスト生成の固定:3.3節の通りknowledge/側のフィールド値をそのまま転記するのみで、テンプレート合成・外部呼び出し(LLM等)を含まない。
  4. 出力のシリアライズ順固定generate_testcases()の戻り値はcase_idで最終ソートしてから返す(generate.rs末尾のtestcases.sort_by)。generated/testcases/書き込み前に既存ディレクトリを丸ごと削除してから再生成する(cli.rsCommand::Generate)ため、削除済みFeature/Conditionのファイルが残留することもない。

これにより、generate_testcases()は同じワーキングツリーに対して冪等であり、markharness verify(1.6節、docs/cli-manual.md)が「CIで再生成 → 既存のgenerated/testcases/*.ymlとバイト単位で比較 → 一致すればOK、不一致なら差分をレビュー要求」というUC2/UC3のフローを実現します。


5. CI検証フロー(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章のアルゴリズムで具体化したものです。


6. エッジケース・限界

ケース 扱い
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の起点はFEATURECONDITIONの両方であるため)。
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.axisTestCase.axisの合成元に使う。behavior.descriptionADR 0015 Phase 1以降、生成には使わない人間向け要約)。Behaviorを持たないConditionからTestCaseは生成されない。

CTM(Classification Tree Method)との関係の再確認

論文§2.3は、CTMを「分類木からのテストケース生成という点で発想を共有するが、Git管理・バージョン履歴・実行結果追跡を含むライフサイクル管理は範囲外」と位置づけています。本設計もこの立場を踏襲し、新しいテスト設計技法を提案するものではなく、Test Designerが既にknowledge/に記述した設計(Feature/Condition/ExpectedResult)を機械的・決定的にTestCaseへ変換する処理であることを明確にします。テスト観点の網羅性・分類軸の設計自体はTest Designerの責務のままです。


7. 将来課題との切り分け

以下は本資料のスコープ外であり、論文§7・付録A.1に委ねます。


8. 検証(実装のテストフィクスチャとの整合確認)

generate.rsの単体テストgenerates_single_testcase_aggregating_all_expected_files_under_one_conditionが使うフィクスチャで3.1節のアルゴリズムを手動でトレースすると、以下の通り実装の出力と一致します。

→ 生成される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] の合成・重複除去・ソート

9. 当初案からの主な変更点(実装時)

本資料の初版(検討ドラフト)は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_idcondition.idのみから機械的に決まる方式(tc-{condition.id}-001)は、当初案が課題としていた「condition_idfeature_idのprefixを部分的に含む場合の重複」問題は回避できていたが、別のBehavior配下で同じcondition.idが再利用された場合にcase_idが衝突するという欠陥があった。これは実運用(AIエージェントによる知識生成)で実際に踏まれ、generated/testcases/のフラットな出力先(condition.idのみをファイル名に使う)により生成ファイルが無言で上書きされる事故につながった。この欠陥への対処は§10を参照。

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スコープに閉じており、この種のグローバルな衝突は検出しない。

これに対して、「グローバル重複チェックを新設する」のではなく、衝突が構造的に起こり得ない設計に変更する方針を採った。

この方針により、「衝突を検出して警告/エラーにする」という別レイヤーの検査ロジックを持つ必要がなくなり、バグのクラス自体を設計から排除できた。後方互換性は考慮していない(破壊的変更)。詳細はchecklist-cli-usability-improvements.mdを参照。