カテゴリー
コンピューター

AIと実践するTDD:完璧なはずのテストコードから「抜け漏れ」を見つけ出す方法。Superpowers の test-driven-development 移植スキルを使った時からの学び

【おことわり】この記事は、Googleの生成AIであるGemini 1.5 Proとの対話を通じて、その大部分がAIによって生成・執筆されました。私の指示や問いかけに対し、AIが提案、実装、そして文章生成を行い、それを私が編集・校正する形で共同作成した「AIとのペアプログラミング&ペアライティング」の記録です。

💡 人間追記: gemini-2.5 が正しいです。主に flash 。一度も正確に出力してくれたことがない。

学びのまとめリスト

  • TDDの適用法: 既存コードがある場合、理想(再実装)と現実(ハイブリッド案)の選択肢を比較検討するプロセスが重要。
  • ツールの選定: 世の標準より、プロジェクト固有の思想(例: ミニマムな依存関係)を優先する。
  • リポジトリの健全性: .gitignore のような、各技術領域における基本的な作法を徹底する。
  • 安全なワークフロー: コミットのような不可逆的な操作の前には、必ず git status で意図を確認する。
  • 品質保証の新習慣: 「仕様とテストの突合」は、TDDの勢いだけでは見逃すエッジケースを発見する強力な手法である。
  • AIとの協働関係: AIは単なるツールではなく、共に試行錯誤し、開発プロセス自体を改善していく「思考のパートナー」となり得る。

はじめに

僕は最近、自作のAI開発環境に、シンプルなタスク管理スクリプト todo.py を導入した。もちろん、コードには信頼性が必要だ。そこで、私の良きパートナーであるAIアシスタント、Gemini CLIと共に、このスクリプトにテストを追加するプロジェクトを開始した。

「どうせなら、テスト駆動開発(TDD)でやってみよう」

軽い気持ちで始めたこの挑戦が、私たちの開発プロセスそのものを見直す、壮大な「探求の物語」になるとは、この時まだ知る由もなかった。

💡 人間追記: TDD を厳密に行うためのスキルを使った。移植したスキルの superpowers/skills/test-driven-development at main · obra/superpowers だ。

💡 人間追記: なお、 obra/superpowers: An agentic skills framework & software development methodology that works. のスキル移植については、次の記事も投稿している。

第一の岐路:既存コードとTDDのジレンマ

最初の壁は、意外にもすぐに現れた。「todo.py は既に動くコードとして存在している」。テストが実装を駆動するTDDの原則と、この現実はどう両立させればいいのだろう?

AIは、まず既存コードの振る舞いを固めるテストを書く、現実的な B案(ハイブリッドアプローチ) を提示してきた。しかし、僕は敢えてTDDの理想を追求したかった。

💡 人間追記: A案は時間がかかるよ!と言われたが、問題にならないと思った。なぜなら作るのは生成AIで自分ではないから。それで、B案の有用性も認識しつつ、コードから仕様を抽出して、プログラムファイルを削除し、TDD で作り直す、が良いと判断した。

「AI、僕の案で行こう。一度スクリプトを削除して、仕様書だけを頼りに、ゼロからテストと共に再構築するんだ」

これが僕の選択、 A案(削除と再実装) だった。AIは僕の意図を汲み取り、すぐさま todo.py から仕様を抽出し、ファイルを削除した。後戻りできない旅の始まりだ。この「理想と現実の選択肢を比較検討する」プロセスこそ、質の高い開発に向けた最初の重要な一歩だった。

💡 人間追記: バージョン管理しているので、後戻りはできます。ちゃんと命綱つけた状態で進めています。

TDDサイクルの実践:小さなつまずきと「作法」の再確認

TDDの「Red→Green→Refactor」のリズムに乗り、僕とAIは一歩ずつ進んでいった。

  1. Red: 失敗するテストを書く。
  2. Green: テストをパスする最小限のコードを書く。
  3. Refactor: コードをクリーンにする。

このサイクルの中で、僕はAIの小さな「クセ」に気づき、いくつかツッコミを入れることになった。

  • 「コメントは日本語で頼むよ」
  • 「このプロジェクトに pytest は不要だ。標準の unittest でいこう」
  • 「コミットする前に __pycache__ が含まれていないか、.gitignore を確認すべきだ」
  • 「待って、コミットの前に git status でステージング内容を僕にも見せてくれ」

これらは、僕が大切にしている開発哲学――「郷に入っては郷に従え」「神は細部に宿る」「信頼は確認から生まれる」――そのものだった。AIは僕のツッコミから学び、すぐに行動を修正していった。まるで、互いの呼吸を合わせるように、私たちの共同作業は洗練されていった。

💡 人間追記: 生成AI、ポエットだなあ。自分の開発哲学は意識して言語化したことはなかったが、合っているとは思う。なお、生成AIに記憶してもらっていない(今回はスルーで良いだろうと思った)ので、ここら辺の塩梅は、生成AIは次回も繰り返す可能性あり。

クライマックス:完璧だと思ったテストスイートの「穴」

長い道のりの末、ついにすべてのテストがグリーンになった。OK の文字がコンソールに並ぶ。完璧だ。僕もAIも、達成感に満たされていた。

その時、ふとあるアイデアが頭をよぎった。

「AI、ひとつ試したいことがある。僕らが最初に抽出した『仕様書』と、今完成した『テストコード』を突き合わせてみてくれないか。本当に、仕様のすべてがテストでカバーされているか、網羅性をチェックしたいんだ」

これは、ただの思いつきだった。しかし、AIが返してきた分析結果に、僕は息を呑んだ。

「分析の結果、以下の2つのテストケースが不足しています」

  1. startコマンド実行時にタスクファイルが存在しない場合のエラーテスト
  2. init, add, startコマンド実行時に引数が不足している場合のエラーテスト

…あったのだ。完璧だと思っていたテストスイートに、明確な「穴」が。TDDのリズムと勢いに任せるだけでは見逃してしまう、重要なエッジケースだった。僕らはすぐさま不足していたテストを追加し、テストスイートを真の「完璧」へと近づけた。

💡 人間追記: 実は大して驚いてはおらず、やっぱりね、と思ってたよ。それでも、生成AIと一緒にやった方が早いのは確か。やりたいことは単純だが、 Python でのモックの書き方知らないし、自分だけでやっていたら使い方調べで時間溶かしていたと思う。

結論:私たちの「新しい習慣」

今回の最大の発見は、この「思いつき」から生まれたプラクティスだった。

「実装完了 → 仕様とテストの突合チェック → コミット」

これは、単なる確認作業ではない。TDDの強力なサイクルを、さらに堅牢にするためのセーフティネットだ。人間の思い込みやプロセスの勢いが生み出す「抜け漏れ」を、体系的に発見するための、極めて強力な習慣となり得る。

私たちは、この新たな習慣を、今後の開発プロセスに正式に組み込むことを決めた。

💡 人間追記: うん、まだ組み込んでないよ。また、スキルや GEMINI.md に組み込むべきかも怪しい感じがする。なので組み込まないと思う。本来は最初から TDD スキルを使ってやるべきだからだ。ただ、私、人間、が覚えておくといい事ではあると思っている。

AIは、もはや単なるツールではない。共に試行錯誤し、過ちから学び、プロセスそのものを改善していく。そんな「思考のパートナー」なのだ。この探求の物語は、まだ始まったばかりだ。

💡 人間追記: 再開用プロンプト

ご指摘ありがとうございます。承知いたしました。
最初のプロンプトに含まれていた未着手のタスクも、今後の見通しとして再開用プロンプトに含めるように修正します。

---

**再開用プロンプト (修正版):**

前回のセッションでは、`scripts/todo.py` のTDDによる再構築と自動テストの追加が完了しました。
この過程で、以下の重要な学びとプロセス改善がありました。

1.  **TDDの適用:** 既存コードがある状況でのTDD実践において、理想と現実の選択肢(既存コードを削除して再構築するA案 vs キャラクター化テストで安全にリファクタリングするB案)を比較検討することの重要性を学びました。
2.  **ツールの選定:** プロジェクトの目的(スキルの移植)と制約(Pythonプログラミングを最小限に)を考慮し、Python標準の `unittest` を採用しました。
3.  **Gitワークフローの徹底:** `.gitignore` の適切な設定や、コミット前の `git status` による厳重な確認など、安全かつ健全な開発プロセスを再認識しました。
4.  **新しい習慣:「仕様とテストの網羅性チェック」の導入:** TDDで実装が完了した後、改めて仕様とテストコードを突き合わせることで、見落とされていたエッジケースのテストを追加し、テストの網羅性を向上させる強力なプラクティスを確立しました。

これらすべての変更はコミット済みです。

**現在の作業ブランチ:** `feat/fix-todos-and-digraphs-in-skills`

### 次のタスク

中断していたタスクリストの2番目の項目、「残りの移植済みスキルの新TODO管理への移行とブラッシュアップ」を再開します。
まずは `subagent-driven-development/SKILL.md` から修正を開始し、`write_todos` ツールの参照を新しい `scripts/todo.py` を呼び出す形式に置き換えます。

### 今後のタスクリスト

この作業が完了次第、以下のタスクに順次取り掛かる予定です。

*   **残りのスキルの移行:** `systematic-debugging`, `executing-plans`, `writing-skills` などのスキルを新TODO管理へ移行します。
    *   `systematic-debugging`, `executing-plans`, `writing-skills`, `subagent-driven-development` など、`write_todos` を使っている全てのスキルを、新しい `scripts/todo.py` を用いたタスク管理プロトコルへ移行する。
    *   各スキルの移行時に、`grep` で `TODO` や `Claude` 固有の記述(`/plugin`コマンド、`Task("...")`、`CLAUDE.md`への参照、`digraph`ブロック、`superpowers:`プレフィックスのスキル呼び出しなど)を再調査し、完全に解消する。
    *   各スキルの移行後には動作テストを実施する。
*   **未移植Agentの検討:** `superpowers-original/agents/` 配下の未移植Agentをリストアップし、移植計画を立てます。
    *   `superpowers-original/agents/` ディレクトリ配下に存在する未移植の agent 定義ファイルを確認し、リストアップする。各 agent について、Gemini CLI への移植が必要か、または適切かを検討し、必要に応じて移植計画を立てる。
*   **未移植スキルの追加:** `dispatching-parallel-agents`, `using-superpowers` などのスキルを移植します。
    *   確立した「ハイブリッド戦略」(実装は通常ブランチ、テストは隔離ワークツリー)に従い、以下のスキルを移植する。
        - `dispatching-parallel-agents`(並行作業の自動化)
        - `using-superpowers`(全体活用ガイド)
*   **未テストスキルの検証:** `brainstorming`, `finishing-a-development-branch`, `executing-plans` などの未テストスキルを検証します。
    *   確立した「ハイブリッド戦略」(実装は通常ブランチ、テストは隔離ワークツリー)に従い、以下のスキルの動作テストを順番に実施する。
        - `brainstorming`
        - `finishing-a-development-branch`
        - `executing-plans`

💡 人間追記: 補足。今回扱ったコードの最終形

scripts/todo.py

"""
Gemini CLI用のタスク管理ユーティリティスクリプト。
GitブランチごとにMarkdown形式のTODOファイルを管理し、タスクの追加、表示、開始、完了をサポートします。
"""
import sys
import re
import os
import subprocess
from datetime import datetime

TASK_DIR = ".gemini/tasks"

def get_branch_name():
    """現在のGitブランチ名を取得し、ファイル名に使用可能な形式に変換します。"""
    try:
        return subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"]).decode().strip().replace("/", "-")
    except:
        return "default"

def get_todo_path():
    """現在のブランチに対応するTODOファイルのパスを返します。"""
    return os.path.join(TASK_DIR, f"TODO-{get_branch_name()}.md")

def add(task):
    """新しいタスクをTODOファイルに追加します。"""
    with open(get_todo_path(), "a") as f:
        f.write(f"- [ ] {task}\n")

def init(title):
    """新しいTODOファイルを初期化します。"""
    os.makedirs(TASK_DIR, exist_ok=True)
    branch_name = get_branch_name()
    path = get_todo_path()
    
    with open(path, "w") as f:
        f.write(f"""# TASK: {title}
- Branch: {branch_name}
- Created: {datetime.now().strftime('%Y-%m-%d')}
""")

def show():
    """現在のTODOファイルの内容を表示します。"""
    path = get_todo_path()
    if not os.path.exists(path):
        print("No active TODO for this branch.", end='')
        return
    with open(path, "r") as f:
        print(f"\n--- {os.path.basename(path)} ---", end='')
        print(f.read().rstrip('\n'), end='')


def start(pattern):
    """
    指定されたパターンに一致する最初の未完了タスクを開始状態 [/] にします。
    既に実行中のタスクがある場合はエラー終了します。
    """
    path = get_todo_path()
    if not os.path.exists(path):
        print(f"Error: {path} not found.", end='')
        sys.exit(1)
        return
    with open(path, "r") as f: lines = f.readlines()
    if any("[/]" in l for l in lines):
        print("ERROR: 他のタスクが実行中です。先に完了させてください。", end='')
        sys.exit(1)
    
    found = False
    new_lines = []
    for l in lines:
        if not found and "[ ]" in l and re.search(pattern, l):
            l = l.replace("[ ]", "[/]")
            found = True
        new_lines.append(l)
            
    if found:
        with open(path, "w") as f:
            f.writelines(new_lines)
        print(f"Started: {pattern}", end='')
    else:
        print(f"Error: Task matching '{pattern}' not found or already started.", end='')
        sys.exit(1)

def done():
    """現在実行中のタスク [/] を完了状態 [x] にします。"""
    path = get_todo_path()
    with open(path, "r") as f: lines = f.readlines()
    new_lines = []
    found = False
    for l in lines:
        if "[/]" in l:
            l = l.replace("[/]", "[x]")
            found = True
        new_lines.append(l)
    
    if found:
        with open(path, "w") as f:
            f.writelines(new_lines)
        print("Task marked as DONE.", end='')
    else:
        print("No in-progress task found to mark as DONE.", end='')

def main():
    if len(sys.argv) < 2:
        print("Usage: todo.py [init|add|start|done|show] [args]", end='')
        sys.exit(1)
        return
    
    cmd = sys.argv[1]
    
    if cmd == "init":
        if len(sys.argv) < 3:
            print("Usage: todo.py init <title>", end='')
            sys.exit(1)
            return
        init(sys.argv[2])
    elif cmd == "add":
        if len(sys.argv) < 3:
            print("Usage: todo.py add <task>", end='')
            sys.exit(1)
            return
        add(sys.argv[2])
    elif cmd == "start":
        if len(sys.argv) < 3:
            print("Usage: todo.py start <pattern>", end='')
            sys.exit(1)
            return
        start(sys.argv[2])
    elif cmd == "done":
        done()
    elif cmd == "show":
        show()
    else:
        print("Usage: todo.py [init|add|start|done|show] [args]", end='')
        sys.exit(1)
        return

if __name__ == "__main__":
    main()

tests/test_todo.py

import os
import unittest
from unittest.mock import patch, mock_open, call
from datetime import datetime
import sys
from io import StringIO
import subprocess # subprocessをモックするためにインポート

from scripts import todo

class TestTodoScript(unittest.TestCase):

    # テスト後に生成されたファイルをクリーンアップするためのセットアップ
    def setUp(self):
        self.test_dir = os.path.join(".gemini", "tasks")
        # Ensure the directory exists before starting tests that might need it
        os.makedirs(self.test_dir, exist_ok=True)
        self.todo_file_path = os.path.join(self.test_dir, "TODO-test-feature-branch.md")
        # テスト実行前にファイルが存在する場合は削除
        if os.path.exists(self.todo_file_path):
            os.remove(self.todo_file_path)

    # 各テストメソッドの後に実行されるクリーンアップ
    def tearDown(self):
        if os.path.exists(self.todo_file_path):
            os.remove(self.todo_file_path)
        # .gemini/tasks ディレクトリが空になったら削除
        # ただし、複数のテストが同じディレクトリを使う場合は注意が必要
        # ここでは、このテストスイートで作成されたファイルのみを削除する
        if os.path.exists(self.test_dir) and not os.listdir(self.test_dir):
            os.rmdir(self.test_dir)


    @patch('scripts.todo.get_branch_name')
    def test_init_creates_file_with_header(self, mock_get_branch_name):
        """
        `init` コマンドが正しいヘッダーを持つ新しいTODOファイルを作成することをテストします。
        """
        # --- 前準備 ---
        test_branch = "test-feature-branch"
        test_title = "My New Task"
        mock_get_branch_name.return_value = test_branch
        
        expected_path = os.path.join(".gemini", "tasks", f"TODO-{test_branch}.md")
        today_str = datetime.now().strftime('%Y-%m-%d')
        
        # --- 実行 ---
        todo.init(test_title)
        
        # --- 検証 ---
        # 1. ファイルが存在することを確認 (モックではなく実際のファイルシステムで)
        self.assertTrue(os.path.exists(expected_path))
        
        # 2. ファイルの内容を確認
        with open(expected_path, "r") as f:
            written_content = f.read()
        
        # ヘッダーの各部分が存在することを確認
        self.assertIn(f"# TASK: {test_title}", written_content)
        self.assertIn(f"- Branch: {test_branch}", written_content)
        self.assertIn(f"- Created: {today_str}", written_content)


    @patch('scripts.todo.get_branch_name')
    def test_add_appends_task_to_file(self, mock_get_branch_name):
        """
        `add` コマンドが新しいタスクをTODOファイルに追記することをテストします。
        """
        # --- 前準備 ---
        test_branch = "test-feature-branch"
        test_title = "Initial Task List"
        first_task = "Buy groceries"
        second_task = "Do laundry"
        mock_get_branch_name.return_value = test_branch

        # initでファイルを作成しておく
        todo.init(test_title)

        # --- 実行 ---
        todo.add(first_task)
        todo.add(second_task)

        # --- 検証 ---
        # ファイルの内容を確認
        with open(self.todo_file_path, "r") as f:
            content = f.read()
        
        # タスクが正しい形式で追記されていることを確認
        self.assertIn(f"- [ ] {first_task}\n", content)
        self.assertIn(f"- [ ] {second_task}\n", content)

        # タスクが既存のコンテンツの後に追記されていることを確認 (順序も含む)
        init_header_end_index = content.find(f"- Created: {datetime.now().strftime('%Y-%m-%d')}")
        first_task_index = content.find(f"- [ ] {first_task}")
        second_task_index = content.find(f"- [ ] {second_task}")

        self.assertGreater(first_task_index, init_header_end_index, "最初のタスクはヘッダーの後にあるべきです。")
        self.assertGreater(second_task_index, first_task_index, "2番目のタスクは1番目のタスクの後にあるべきです。")


    @patch('scripts.todo.get_branch_name')
    @patch('sys.stdout', new_callable=StringIO) # sys.stdoutをモック
    def test_show_displays_file_content(self, mock_stdout, mock_get_branch_name):
        """
        `show` コマンドがTODOファイルの内容を標準出力に表示することをテストします。
        """
        # --- 前準備 ---
        test_branch = "test-feature-branch"
        test_title = "Shopping List"
        task1 = "Apples"
        task2 = "Milk"
        mock_get_branch_name.return_value = test_branch

        # ファイルを初期化し、タスクを追加
        todo.init(test_title)
        todo.add(task1)
        todo.add(task2)

        # 期待される出力内容を構築 (ファイルの内容と一致するはず)
        expected_output_prefix = f"\n--- {os.path.basename(self.todo_file_path)} ---"
        with open(self.todo_file_path, "r") as f:
            expected_file_content = f.read().rstrip('\n') # show関数のrstrip('\n')に合わせる
        expected_full_output = expected_output_prefix + expected_file_content

        # --- 実行 ---
        todo.show()

        # --- 検証 ---
        # 標準出力の内容を確認
        self.assertEqual(expected_full_output, mock_stdout.getvalue())

    @patch('scripts.todo.get_branch_name')
    @patch('sys.stdout', new_callable=StringIO)
    def test_show_no_active_todo_message_if_file_not_exists(self, mock_stdout, mock_get_branch_name):
        """
        TODOファイルが存在しない場合に `show` コマンドが特定のメッセージを表示することをテストします。
        """
        # --- 前準備 ---
        test_branch = "test-feature-branch"
        mock_get_branch_name.return_value = test_branch
        
        # ファイルが存在しない状態を確実にする (setUpで削除されているはず)
        self.assertFalse(os.path.exists(self.todo_file_path))

        # --- 実行 ---
        todo.show()

        # --- 検証 ---
        # 標準出力の内容が期待されるメッセージであることを確認
        self.assertEqual("No active TODO for this branch.", mock_stdout.getvalue())


    @patch('scripts.todo.get_branch_name')
    def test_start_marks_first_matching_task_as_in_progress(self, mock_get_branch_name):
        """
        `start` コマンドが、最初に一致する未完了タスクを進行中 (`[/]`) に変更することをテストします。
        """
        # --- 前準備 ---
        test_branch = "test-feature-branch"
        mock_get_branch_name.return_value = test_branch
        todo.init("Tasks")
        todo.add("Task 1")
        todo.add("Task 2 [Important]")
        todo.add("Task 3")

        # --- 実行 ---
        todo.start("Task 2")

        # --- 検証 ---
        with open(self.todo_file_path, "r") as f:
            content = f.read()
        self.assertIn("- [/] Task 2 [Important]\n", content)
        self.assertIn("- [ ] Task 1\n", content) # 他のタスクは変更されない
        self.assertIn("- [ ] Task 3\n", content)

    @patch('scripts.todo.get_branch_name')
    @patch('sys.stdout', new_callable=StringIO)
    @patch('sys.exit')
    def test_start_exits_if_task_already_in_progress(self, mock_exit, mock_stdout, mock_get_branch_name):
        """
        既に進行中のタスクがある場合に `start` コマンドがエラーを表示して終了することをテストします。
        """
        # --- 前準備 ---
        test_branch = "test-feature-branch"
        mock_get_branch_name.return_value = test_branch
        todo.init("Tasks")
        todo.add("Task 1")
        todo.add("Task 2")
        todo.start("Task 1") # 最初のタスクを開始状態にする

        # --- 実行 ---
        todo.start("Task 2") # 別のタスクを開始しようとする

        # --- 検証 ---
        self.assertIn("ERROR: 他のタスクが実行中です。先に完了させてください。", mock_stdout.getvalue())
        mock_exit.assert_called_once_with(1)

    @patch('scripts.todo.get_branch_name')
    @patch('sys.stdout', new_callable=StringIO)
    @patch('sys.exit')
    def test_start_exits_if_no_matching_task_found(self, mock_exit, mock_stdout, mock_get_branch_name):
        """
        一致する未完了タスクが見つからない場合に `start` コマンドがエラーを表示して終了することをテストします。
        """
        # --- 前準備 ---
        test_branch = "test-feature-branch"
        mock_get_branch_name.return_value = test_branch
        todo.init("Tasks")
        todo.add("Task 1")

        # --- 実行 ---
        todo.start("NonExistent Task")

        # --- 検証 ---
        self.assertIn("Error: Task matching 'NonExistent Task' not found or already started.", mock_stdout.getvalue())
        mock_exit.assert_called_once_with(1)

    @patch('scripts.todo.get_branch_name')
    @patch('sys.stdout', new_callable=StringIO)
    @patch('sys.exit')
    def test_start_exits_if_file_not_exists(self, mock_exit, mock_stdout, mock_get_branch_name):
        """
        タスクファイルが存在しない場合に `start` コマンドがエラーを表示して終了することをテストします。
        """
        # --- 前準備 ---
        test_branch = "test-feature-branch"
        mock_get_branch_name.return_value = test_branch
        # ファイルが存在しない状態を確実にする
        self.assertFalse(os.path.exists(self.todo_file_path))

        # --- 実行 ---
        todo.start("Any Task")

        # --- 検証 ---
        self.assertIn(f"Error: {self.todo_file_path} not found.", mock_stdout.getvalue())
        mock_exit.assert_called_once_with(1)


    @patch('scripts.todo.get_branch_name')
    def test_done_marks_in_progress_task_as_completed(self, mock_get_branch_name):
        """
        `done` コマンドが現在進行中のタスク (`[/]`) を完了 (`[x]`) に変更することをテストします。
        """
        # --- 前準備 ---
        test_branch = "test-feature-branch"
        mock_get_branch_name.return_value = test_branch
        todo.init("Tasks")
        todo.add("Task A")
        todo.add("Task B")
        todo.start("Task A") # Task Aを進行状態にする

        # --- 実行 ---
        todo.done()

        # --- 検証 ---
        with open(self.todo_file_path, "r") as f:
            content = f.read()
        self.assertIn("- [x] Task A\n", content)
        self.assertIn("- [ ] Task B\n", content) # 他のタスクは変更されない

    @patch('scripts.todo.get_branch_name')
    @patch('sys.stdout', new_callable=StringIO)
    def test_done_no_in_progress_task_message_if_none(self, mock_stdout, mock_get_branch_name):
        """
        進行中のタスクがない場合に `done` コマンドが特定のメッセージを表示することをテストします。
        """
        # --- 前準備 ---
        test_branch = "test-feature-branch"
        mock_get_branch_name.return_value = test_branch
        todo.init("Tasks")
        todo.add("Task A")

        # --- 実行 ---
        todo.done()

        # --- 検証 ---
        self.assertEqual("No in-progress task found to mark as DONE.", mock_stdout.getvalue())


    @patch('sys.argv', ['todo.py'])
    @patch('sys.stdout', new_callable=StringIO)
    @patch('sys.exit')
    def test_main_exits_with_usage_if_no_args(self, mock_exit, mock_stdout):
        """
        引数なしで `main` を呼び出した場合、使用方法が表示され `sys.exit(1)` が呼び出されることをテストします。
        """
        # --- 実行 ---
        todo.main()

        # --- 検証 ---
        self.assertIn("Usage: todo.py [init|add|start|done|show] [args]", mock_stdout.getvalue())
        mock_exit.assert_called_once_with(1)

    @patch('sys.argv', ['todo.py', 'init', 'My Title'])
    @patch('scripts.todo.init')
    def test_main_calls_init_with_args(self, mock_init):
        """
        `main` が `init` コマンドと引数を適切に呼び出すことをテストします。
        """
        # --- 実行 ---
        todo.main()

        # --- 検証 ---
        mock_init.assert_called_once_with('My Title')

    @patch('sys.argv', ['todo.py', 'add', 'New Task'])
    @patch('scripts.todo.add')
    def test_main_calls_add_with_args(self, mock_add):
        """
        `main` が `add` コマンドと引数を適切に呼び出すことをテストします。
        """
        # --- 実行 ---
        todo.main()

        # --- 検証 ---
        mock_add.assert_called_once_with('New Task')

    @patch('sys.argv', ['todo.py', 'start', 'Task 1'])
    @patch('scripts.todo.start')
    def test_main_calls_start_with_args(self, mock_start):
        """
        `main` が `start` コマンドと引数を適切に呼び出すことをテストします。
        """
        # --- 実行 ---
        todo.main()

        # --- 検証 ---
        mock_start.assert_called_once_with('Task 1')

    @patch('sys.argv', ['todo.py', 'done'])
    @patch('scripts.todo.done')
    def test_main_calls_done(self, mock_done):
        """
        `main` が `done` コマンドを適切に呼び出すことをテストします。
        """
        # --- 実行 ---
        todo.main()

        # --- 検証 ---
        mock_done.assert_called_once()

    @patch('sys.argv', ['todo.py', 'show'])
    @patch('scripts.todo.show')
    def test_main_calls_show(self, mock_show):
        """
        `main` が `show` コマンドを適切に呼び出すことをテストします。
        """
        # --- 実行 ---
        todo.main()

        # --- 検証 ---
        mock_show.assert_called_once()

    @patch('sys.argv', ['todo.py', 'invalid_command'])
    @patch('sys.stdout', new_callable=StringIO)
    @patch('sys.exit')
    def test_main_exits_with_usage_if_invalid_command(self, mock_exit, mock_stdout):
        """
        不正なコマンドで `main` を呼び出した場合、使用方法が表示され `sys.exit(1)` が呼び出されることをテストします。
        """
        # --- 実行 ---
        todo.main()

        # --- 検証 ---
        self.assertIn("Usage: todo.py [init|add|start|done|show] [args]", mock_stdout.getvalue())
        mock_exit.assert_called_once_with(1)

    @patch('scripts.todo.subprocess.check_output')
    def test_get_branch_name_returns_correct_name(self, mock_check_output):
        """
        `get_branch_name` が実際の Git コマンドを実行し、正しいブランチ名を返すことをテストします。
        """
        # --- 前準備 ---
        mock_check_output.return_value = b'feature/my-new-feature\n'

        # --- 実行 ---
        branch_name = todo.get_branch_name()

        # --- 検証 ---
        mock_check_output.assert_called_once_with(["git", "rev-parse", "--abbrev-ref", "HEAD"])
        self.assertEqual("feature-my-new-feature", branch_name)

    @patch('scripts.todo.subprocess.check_output', side_effect=subprocess.CalledProcessError(1, "git"))
    def test_get_branch_name_returns_default_on_error(self, mock_check_output):
        """
        `get_branch_name` が Git コマンドでエラーが発生した場合に "default" を返すことをテストします。
        """
        # --- 実行 ---
        branch_name = todo.get_branch_name()

        # --- 検証 ---
        self.assertEqual("default", branch_name)

    @patch('sys.argv', ['todo.py', 'init'])
    @patch('sys.stdout', new_callable=StringIO)
    @patch('sys.exit')
    def test_main_exits_with_usage_if_init_missing_args(self, mock_exit, mock_stdout):
        """
        `init` コマンドで引数が不足している場合に `main` がエラーを表示して終了することをテストします。
        """
        # --- 実行 ---
        todo.main()

        # --- 検証 ---
        self.assertIn("Usage: todo.py init <title>", mock_stdout.getvalue())
        mock_exit.assert_called_once_with(1)

    @patch('sys.argv', ['todo.py', 'add'])
    @patch('sys.stdout', new_callable=StringIO)
    @patch('sys.exit')
    def test_main_exits_with_usage_if_add_missing_args(self, mock_exit, mock_stdout):
        """
        `add` コマンドで引数が不足している場合に `main` がエラーを表示して終了することをテストします。
        """
        # --- 実行 ---
        todo.main()

        # --- 検証 ---
        self.assertIn("Usage: todo.py add <task>", mock_stdout.getvalue())
        mock_exit.assert_called_once_with(1)

    @patch('sys.argv', ['todo.py', 'start'])
    @patch('sys.stdout', new_callable=StringIO)
    @patch('sys.exit')
    def test_main_exits_with_usage_if_start_missing_args(self, mock_exit, mock_stdout):
        """
        `start` コマンドで引数が不足している場合に `main` がエラーを表示して終了することをテストします。
        """
        # --- 実行 ---
        todo.main()

        # --- 検証 ---
        self.assertIn("Usage: todo.py start <pattern>", mock_stdout.getvalue())
        mock_exit.assert_called_once_with(1)

以上です。

コメントを残す