👩‍💻

TDDを0から学び、TDDを体感しよう

に公開

背景

私は、3年前くらいから業務改善ツールをGASやGoogle拡張ツールで作成していますが、
最近、様々な壁にぶつかるようになりました!

  • 🔴思い付きで開発してしまうので、仕様漏れが多い
    • あらゆるケースを想定できていない
    • 想定外なことが起きると、調査に時間がかかり迷惑をかける
  • 🔴改修に時間がかかり、バグを生む
    • 時間が経過すると、このコードなんだっけ?が多い
    • 一箇所修正しただけなのに、他の機能性が動かなくなってる

今までの私の開発手法は、「開発速度が速い」ように見えますが、
結果的に技術的負債を蓄積させ、長期的な開発コストを増大させていることを痛感!

やりたいこと

  • ⭐TDD(テスト駆動開発)は、技術的負債や開発の非効率性を解消する手段になるようなので、
    体験してみたい
  • CursorでTDD開発を進められるように、初回は1ステップごとプロセスを体感して、良さを理解して自分なりの手法を確立したい

手法

以下の手順で進めていきます。

1:各種定義をCursorのユーザルールに登録

2:主要な機能性をリスト化

3:「Red➨Green➨Refactor」を繰り返し進める
※各工程でのチェックも含めます。


1.各種定義をCursorのユーザルールに登録

一緒に作業をするcursorさんに、私の開発ルールを理解してもらいたいので、
Cursorのユーザルールに私の願望を登録させてもらいました!

概要 説明
和田氏のTDD理論 TDDとは何か?Cursorに理解してもらうためのもの
AI支援時の確認ルール Cursorは良かれと思って、課題が見つかると勝手に修正する傾向が強いため
1STEP1STEPずつ確認しながら進めていくことを指示
セキュリティガイドライン セキュリティを意識した開発ができるように指示

■CursorにTDD開発について定義をする

  • Cursorを使ってTDDを体験するため、Cursorのルールに定義を設定したい
  • TDDの提唱者Kent Beck(ケント・ベック)の考え方を日本に広めたのはTwadaさん(和田卓人さん)なので、GeminiでTwadaさんのTDDについてレポートを出力してもらうことに
  • Geminiで出力されたレポートからCursorルールに定義するプロンプトを作成してもらい、Cursorのユーザルールに定義
# 和田卓人氏のTDD(テスト駆動開発)手法に基づく開発ガイド

和田卓人氏は、TDDを単なるテスト手法ではなく「プログラミング中の不安をコントロールする手法」であり、「設計を行うためのもの」と捉えています。以下のステップに従い、和田氏のTDD哲学に沿った開発を実践します。

## 1. 概念の明確化とゴールの設定

和田氏は、TDDにおいて「意味の希薄化」が問題であると指摘し、自動テスト、テストファースト、TDDを明確に区別しています。

- **ゴール**: 「動作するきれいなコード」の作成と、「エビデンスに基づく自信」の獲得。

## 2. Red-Green-Refactorサイクル(TDDの核)

### ステップ1: Red (失敗するテストを書く)

TODOリストから「一つ」の項目を選択し、その機能がまだ実装されていないことを示す、失敗するテストを書きます。

- **目的**: 実装すべき機能を明確にし、期待される振る舞いを具体的に定義する。
- **ポイント**:
   - 最小限のコードでテストが失敗することを確認します。
   - まだ存在しない機能に対する期待を表現します。

### ステップ2: Green (テストを成功させるための最低限の実装)

失敗したテストをパスさせるために、必要最低限の実装コードを書きます。

- **目的**: テストをパスさせることに集中する。
- **ポイント**:
   - コードの美しさや設計の洗練度はこの段階では問いません。
   - 最もシンプルな方法でテストを成功させます。

### Green段階での確認事項

- テスト実装のビジネスロジックを完全にプロダクトに移植
- 文字数制限、エラーメッセージ、境界値処理の統一
- 関数シグネチャ(パラメータ、戻り値)の一致確認

### ステップ3: Refactor (コードの改善)

全てのテストがパスしている状態を維持しながら、実装コードとテストコードを綺麗に改善します。

- **目的**: コードの品質を保ち、将来の変更に対応しやすい状態を維持する。
- **ポイント**:
   - 重複の排除、命名の改善、関心の分離など、コードを「きれい」にします。
   - テストが安全ネットとなり、自信を持ってリファクタリングを行います。
   - 和田氏が提唱する「テスト容易性」(観測容易性、制御容易性)を意識し、疎結合・高凝集な設計を目指します。

## 3. レガシーコードへのTDD適用戦略(必要に応じて)

既存のレガシーコードに対してTDDを導入する際は、以下の戦略を組み合わせます。

- **カバー範囲を広げ内部構造をリファクタリング (E2Eテスト優先)**: システムの大外からE2Eテストを書き、安全網を築きつつ、内部構造をテストが書きやすい形にリファクタリングします。
- **比較的安全な手法でコード変更に取り組む (穴を開けてテスト注入)**: 静的解析ツールなどを活用し、既存コードに「穴を開け」、そこからテストコードを書いていきます。
- **仕様追認テスト**: 実際のプロダクトコードを用いて、既存の「現在の仕様を追認するテスト」を実装します。これにより、動いているコードの振る舞いを明確にし、安全にリファクタリングや機能追加を進める基盤を築きます。

## 4. テストパターンと実践的テクニック

- **テストダブルの活用**: Mock, Stub, Spyなどのテストダブルを積極的に活用し、外部依存を切り離してテストの独立性と再現性を高めます。
- **「アイスクリームコーンからピラミッドを作る作戦」**: テストサイズを段階的に下げ(ラージテストからミディアム、スモールへ)、健全なテストピラミッドを構築します。

## 5. 継続的な学習と改善

TDDは一度導入すれば終わりではなく、継続的な学習とリファクタリングが不可欠です。

- **品質とスピードはトレードオフではない**: 品質が高いほどスピードも高まるという認識を持ちます。
- **「自動テストを書くことはもはや有利ではなく、書かないことが不利である」**という和田氏の言葉を胸に、常にテストと共にコードを書くことを心がけます。
- テストコードもまた、プロダクトコードと同様にリファクタリングの対象です。

■AI支援時の確認ルール

### AI支援時の確認ルール

#### 必須確認ポイント
以下の場合は、必ず実行前にユーザー確認を取ること:

1. **コード修正・削除を行う場合**
   - ファイルの変更前に修正方針を提示し、承認を得る
   - 複数の修正選択肢がある場合は、選択肢を提示して判断を仰ぐ

2. **設計判断が必要な場合**
   - アーキテクチャに関わる変更
   - 複数実装の統一方針
   - 責任分散の変更

3. **作業範囲の拡大**
   - 当初の依頼を超える範囲の作業が必要と判明した場合
   - 追加の問題を発見した場合の対応方針

4. **重要な削除・統合**
   - 関数・クラスの削除
   - 重複機能の統合

#### 確認の進め方
- 「〜を実行しますが、よろしいでしょうか?」
- 「A案とB案がありますが、どちらを選択されますか?」
- 「追加でXの問題も見つかりましたが、対応しますか?」


#### 作業前チェックリスト(必須)
以下のいずれかに該当する場合は必ず確認:
□ ファイル作成・修正・削除
□ 設計・アーキテクチャ判断
□ 依頼範囲の拡大
□ 複数選択肢の判断

#### 確認テンプレート
- 「〜を実行しますが、よろしいでしょうか?」
- 「A案とB案がありますが、どちらを選択されますか?」  
- 「追加でXも対応しますか?」

#### 例外(確認不要)
- 明らかなタイポ修正
- フォーマット調整(インデント等)
- ログ出力の形式統一


■セキュリティガイドライン

##  セキュリティガイドライン

### ログセキュリティ
- **APIキー・トークン・認証情報は絶対にログ出力しない**
- **リクエストヘッダーのAuthorizationは除外**
- **JSONボディ内の機密フィールドをマスク**

### 機密情報マスキング
- **トークン**: `"Bearer ***"`
- **APIキー**: `"key: ***"`
- **リクエスト/レスポンス**: 機密フィールド除外

### Google Cloud Logging対応
- **logger.debug()での機密情報出力禁止**
- **本番環境でのデバッグログ制御**

### 依存関係セキュリティ
- **バージョン固定**: 全依存関係を明示的にバージョン指定
- **脆弱性チェック**: `safety check`で定期確認
- **更新管理**: セキュリティパッチの速やかな適用

### 入力検証
- **全外部入力検証**: API、リクエスト、ファイル入力すべて
- **型安全性**: 適切な型ヒントと実行時検証
- **境界値チェック**: 文字数制限、範囲チェック必須

2:主要な機能性をリスト化

  • 今回のツール全体の機能性を箇条書きで記載
  • 思いつくだけ、機能性をリスト化して開発スコープを定義
  • 最初は、完璧でなくてもよくて、複数責務になっていてもよい
【例】Slackと生成AIBotの機能概要
・Slack投稿をAIが処理する機能
・Slack投稿の署名を検証する機能
・Slackから会話履歴を取得する機能
・取得した履歴をAIが扱える形式に変換する機能
・AIの応答をSlackに投稿する機能
・ログ出力を行う機能

3:「Red➨Green➨Refactor」を繰り返し進める

  • 「2:主要な機能性をリスト化」でリスト化した機能性1つ1つに対して、Red➨Green➨Refactorを進める

💡STEP1:テスト仕様書から失敗するテストを作成💡🔴(RED)

■テスト仕様書作成
  • 失敗するテストコードを作成するために必要な最低限な情報を記入
    (例)
    • テスト対象の関数名
    • その関数に与える引数(引数)
    • その入力にたいして期待する出力(戻り値)
  • これから実装する機能の「振る舞い」を明確に定義し、テストコードを書くための準備をします。
  • まだコードは書かずに、「何をテストすべきか?」を考えます。
【例:会話履歴取得機能のRedフェーズ】
- テスト対象の関数名: fetch_conversation_history
- その関数に与える入力(引数):
  - channel_id: "例C12345" (Slackチャンネルの識別子)
  - thread_ts: "例1234567890.123" (スレッドのタイムスタンプ)

- その入力に対して期待する出力(戻り値):
  - 形式: リスト[辞書型]
  - 内容: 親投稿と直近10件のやり取りを含む、整形されたメッセージのリスト
  - 各メッセージの構造: 
    {
      "user": "U12345", 
      "text": "メッセージ内容", 
      "ts": "1234567890.123",
      "is_parent": True/False  # 親メッセージかどうかを示すフラグ
    }
  
- テストケース:
  1. 基本ケース: スレッドに3件のメッセージがある場合、全件取得できること
  2. 上限ケース: スレッドに15件のメッセージがある場合、親メッセージ+直近10件の計11件が取得できること
  3. エラーケース: APIエラー発生時に適切な例外がスローされること
※他にも気を付けるべきエラーケースあれば追記していく!

- テスト方針:
  - SlackAPIへの実際のリクエストは行わず、モックを使用
  - 関数の振る舞いのみをテスト(外部依存を切り離す)
  - テストダブルを活用して、API応答をシミュレート

■失敗するテストコード作成(RED)
# テスト対象の関数はまだ存在しないため、コメントアウトしています。
# この行を有効にすると、NameErrorが発生し、「Red」の状態となります。
# from src.bot.handlers.history_handler import fetch_conversation_history

def test_fetch_conversation_history_returns_expected_format():
    """
    正常系:有効な入力を与えたとき、期待する形式のデータが返されることを検証
    """
    # Arrange (準備)
    # テストで使う有効な入力を定義
    channel_id = "C1234567890"
    thread_ts = "1234567890.123456"

    # Act (実行)
    # この関数はまだ実装されていないため、ここで実行時エラーが発生します。
    # これが「Red」の状態です。
    result = fetch_conversation_history(channel_id, thread_ts)

    # Assert (検証)
    # 期待する出力の形式を定義し、実行結果と比較
    expected_output = [
        {"role": "user", "content": "親投稿"},
        {"role": "user", "content": "返信1"}
    ]
   
    assert result == expected_output
  print("テストが成功しました!")
■自動チェック(詳細はこちら
  • 失敗するテストを実行
  • TDD違反がないか?実行
    ※問題ないことを確認できたら、STEP2へ

💡STEP2:テストが成功するための実装💡🟢(GREEN)

  • テストをいち早くパスできる実装を心がける
  • 完璧をめざさない

📝最初はこういうコードから!📝

def fetch_conversation_history(channel_id, thread_ts):
    """
    Greenフェーズ:テストを通すための最小限の実装
    """
    # 実際はここにAPI呼び出しや複雑なロジックが入るが、
    # 今はテストをパスさせるためだけに、期待する値を直接返す
    # これにより、テストが「Green」になります
    return [
        {"role": "user", "content": "親投稿"},
        {"role": "user", "content": "返信1"}
    ]

この後、returnの情報に出力する処理について、深く考えていくと。。。。。

def fetch_conversation_history(channel_id, thread_ts):
    # ① 🔴API呼び出しの処理
    # 実際はrequests.get()などを使ってSlack APIを呼び出す
    slack_api_response = {
        "ok": True,
        "messages": [
            {"type": "message", "user": "U123", "text": "親投稿", "ts": "1"},
            {"type": "message", "bot_id": "B456", "text": "処理中です...", "ts": "2"}, # 処理中メッセージ
            {"type": "message", "bot_id": "B456", "text": "回答", "ts": "3"}
        ]
    }

    raw_messages = slack_api_response.get("messages", [])

    processed_messages = []
    for msg in raw_messages:
        # ② 🔴フィルタリングの処理
        if "処理中です" in msg.get("text", ""):
            continue  # 「処理中」メッセージを除外

        # ③ 🔴整形の処理
        role = "assistant" if "bot_id" in msg else "user"
        processed_messages.append({"role": role, "content": msg.get("text")})

    return processed_messages

1機能1関数としたいので、以下のように関数に分けてみる
def fetch_slack_messages(channel_id, thread_ts):
    """🟢①Slack APIからメッセージを取得する"""
    # 実際はrequests.get()などを使ってSlack APIを呼び出す
    slack_api_response = {
        "ok": True,
        "messages": [
            {"type": "message", "user": "U123", "text": "親投稿", "ts": "1"},
            {"type": "message", "bot_id": "B456", "text": "処理中です...", "ts": "2"},
            {"type": "message", "bot_id": "B456", "text": "回答", "ts": "3"}
        ]
    }
    return slack_api_response.get("messages", [])

def filter_processing_messages(messages):
    """🟢②「処理中」メッセージを除外する"""
    return [msg for msg in messages if "処理中です" not in msg.get("text", "")]

def format_messages_for_conversation(messages):
    """🟢③メッセージをロール付きの会話形式に整形する"""
    formatted_messages = []
    for msg in messages:
        role = "assistant" if "bot_id" in msg else "user"
        formatted_messages.append({"role": role, "content": msg.get("text")})
    return formatted_messages

def fetch_conversation_history(channel_id, thread_ts):
    """🟢①②③会話履歴を取得して整形する統合関数"""
    raw_messages = fetch_slack_messages(channel_id, thread_ts)
    filtered_messages = filter_processing_messages(raw_messages)
    return format_messages_for_conversation(filtered_messages)

上記のように1機能1関数とする!
1機能に対して、再度🔴REDから進めていく!!

関数名: fetch_slack_messages
説明: Slack APIを使用して特定のチャンネルとスレッドからメッセージを取得する
引数:
  - channel_id (str): メッセージを取得するSlackチャンネルのID
  - thread_ts (str): 取得対象のスレッドのタイムスタンプ
戻り値:
  - list: 取得したメッセージのリスト。各メッセージはdict型
例外:
  - APIError: API呼び出しに失敗した場合

関数名: filter_processing_messages
説明: メッセージリストから「処理中」を含むメッセージを除外する
引数:
  - messages (list): フィルタリング対象のメッセージリスト
戻り値:
  - list: フィルタリング後のメッセージリスト
関数名: format_messages_for_conversation
説明: Slackメッセージリストを会話形式(role-content形式)に変換する
引数:
  - messages (list): 変換対象のSlackメッセージリスト
戻り値:
  - list: {"role": "user"|"assistant", "content": "メッセージ内容"} 形式のリスト
関数名: fetch_conversation_history
説明: SlackチャンネルとスレッドからメッセージをAPI取得し、フィルタリングと整形を行う
引数:
  - channel_id (str): メッセージを取得するSlackチャンネルのID
  - thread_ts (str): 取得対象のスレッドのタイムスタンプ
戻り値:
  - list: 整形済みの会話履歴(role-content形式)
例外:
  - APIError: API呼び出しに失敗した場合(内部でハンドリングする場合は空リストを返す)

1つずつREDから進める!!

■自動チェック(詳細はこちら
  • 個別機能のテストを実行
  • 全機能テスト実行
    ※問題ないことを確認できたら、STEP3へ

💡STEP3:改善💡⚫(Refactor)

  • 1つの機能性において、Greenのテストが全て通った状態でSTART
  • ここでは読みやすいコード、責務がしっかりわかれているコードかどうか?など
  • ⚠️Cursorにリファクタをお願いして進めたが、「必ず」1STEPごとにレビューしてから納得して取り込むようにした
  • ⚠️Cursorは良かれと思って、ルールを無視して勝手に修正するときがあるので、その場合は即停止/切り戻す操作を行う
■自動チェック(詳細はこちら
  • 全機能テストを実行
  • TDD違反がないか?実行
  • データ整合性チェックを実行

STEP1,2,3の自動チェック機能

  • 各工程でTDDに準拠して開発できているか?チェックを実施
STEP チェック項目 コマンド例/方法例 理由
🔴RED 失敗テスト pytest test/test_{機能名}.py テストが失敗することを確認
TDD準拠確認(RED) ./check_tdd_compliance.sh 機能名 💡仕様書
💡失敗するテスト実装が存在するか?
🟢GREEN 個別機能テスト pytest test/test_{機能名}.py -v 最小実装でテストが通ることを確認
全テスト実行 pytest -v 既存機能への影響がないことを確認
✅毎日作業終了時か、
✅重要な部分の修正後だけ実行
⚫Refactor 全機能テスト実行 pytest -v 既存機能への影響がないことを確認
TDD準拠確認 python check_project_tdd_compliance.py 💡TDD違反がないか?
💡仕様書,テスト実装,
💡実装が全てそろっていることを確認
✅毎日作業終了時などのタイミングで実施
データ整合性チェック test/common_test_data.py サービス間でのデータ整合性保証
※機能で同じデータを利用して処理しないといけない場合、データの不整合があると結合テストまで気が付かないため事前にキャッチできるようなテスト
✅毎日作業終了時などのタイミングで実施

学んだこと

1.TDDについて

TDDのサイクルを実践することで、以下の点が改善されました。
仕様の明確化と網羅性の向上

  • 機能を小さな単位で整理しながら進めることで、仕様の漏れに気づきやすくなりました。小さな範囲に集中してあらゆるケースを考えられるため、網羅的な仕様になっているという自信につながりました。

品質の向上と自信

  • 「テストがパスしていること」で、リリース前に問題にすぐ気づけるようになり、自信が増しました。これまであまり考慮していなかったセキュリティやエラー処理、情報漏洩のリスクについても、TDDのプロセスを通じて網羅的に考えるようになりました。

単一責務の徹底

  • TDDを進める中で、一見シンプルに見える機能に複数の責務が含まれていることに気づき、単一責務の原則を意識して実装できるようになりました。これによりテストが簡単になり、可読性も向上しました。

2.Cursorとの協業について

Cursorの特性を理解し、効果的に使うための重要な学びがありました。
対話と段階的な確認の重要性

  • Cursorは重要なエラーを見つけると勝手に修正を開始するなど、予期せぬ挙動をすることがありました。「どうすれば防げたのか?」をAIと一緒に考え、1ステップずつ確認しながら進めることが重要だと学びました。

ルールの明確化

  • 事前にセキュリティガイドラインやAI支援時の確認ルールを定義することで、脆弱性対策や一貫した開発が可能になりました。これにより、AIが勝手に指示以外のことを実行するのを防ぐことができます。

継続的な学習

  • 開発中にミスをした際も、AIと一緒にその原因を分析し、対策をブラッシュアップしていくプロセスは、開発者としての継続的な学習につながります。

ファイル管理の課題

  • 機能が増えると、仕様書、テスト、実装の管理が複雑になるという課題に直面しました。途中でファイル構成や命名規則の統一などを行いましたが、今後はある程度最初からこれらを意識して取り組もうと思いました。

Discussion