テスト駆動開発:AIにユニットテストを書かせる

こんにちは、松山です。
これは「AI駆動開発実践」シリーズの第8篇です。
今日はAIテストを一緒に勉強しましょう。

シリーズの目次

第1篇 AIプログラミングの新時代:Claude Codeとは?
第2篇 10分で始める:Claude Codeのインストールと設定完全ガイド
第3篇 基本操作:Claude Codeとの正しい対話の仕方
第4篇 AIにあなたのプロジェクトを理解させる:コンテキスト管理のテクニック
第5篇 コードリファクタリングの神器:AIに古いコードを最適化させる
第6篇 Bugターミネーター:AIアシストデバッグの効率的な方法
第7篇 ゼロから始める:Claude Codeで完全なアプリケーションを構築

「テスト書くの面倒だから、機能が完成してからにしよう…」
「テストカバレッジ90%が要求されてるけど、今20%しかない…」
「この関数どうテストすればいいの?エッジケースが多すぎる…」

これ、あなたの心の声じゃないですか?

良いニュース: Claude Codeはテストを書くのが得意です。
もっと良いニュース: AIが書いたテストは、人が書いたものより包括的なことが多いです。

今日は、AIをあなたのテストエンジニアにする方法を教えます。

なぜAIはテストが得意?

AIの強み

1. 考慮が包括的

  • 正常ケース
  • 境界条件
  • 異常ケース
  • 極端な入力

人は見落としがち、AIは体系的にカバー。

2. 高速生成

  • 数秒で数十個のテストケースを生成
  • 自動でモックデータ設計
  • 規範的なテスト構造

3. 継続的改善

  • コードロジックを分析
  • 未テストのパスを識別
  • 追加ケースを提案

テストケース設計

テクニック1:既存関数にテストを補う

シーン: テストのない関数がある

# utils/validators.py
def validate_email(email: str) -> bool:
    """メールアドレス形式が正しいか検証"""
    import re
    pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
    return re.match(pattern, email) is not None
あなた:validate_email関数の完全なユニットテストを書いて、
様々なケースをカバーしてください

Claude:関数ロジックを分析中…

この関数はメールアドレス形式を検証します。テストすべき項目:
1. 有効なメールアドレス形式
2. 無効なメールアドレス形式(様々なエラー)
3. 境界ケース

tests/test_validators.pyを作成:

import pytest
from utils.validators import validate_email

class TestValidateEmail:
    """メールアドレス検証テスト"""

    # 有効なメールアドレス
    @pytest.mark.parametrize("email", [
        "user@example.com",
        "test.user@example.com",
        "user+tag@example.co.uk",
        "user_name@example.org",
        "123@example.com",
        "user@sub.example.com",
    ])
    def test_valid_emails(self, email):
        assert validate_email(email) is True

    # 無効なメールアドレス
    @pytest.mark.parametrize("email", [
        "invalid",                    # @がない
        "@example.com",               # ユーザー名がない
        "user@",                      # ドメインがない
        "user @example.com",          # スペースを含む
        "user@example",               # トップレベルドメインがない
        "user@@example.com",          # @が重複
        "user@exam ple.com",          # ドメインにスペース
        "",                           # 空文字列
        "user@.com",                  # ドメインが.で始まる
        "user@example..com",          # 連続したドット
    ])
    def test_invalid_emails(self, email):
        assert validate_email(email) is False

    # 境界ケース
    def test_very_long_email(self):
        """超長メールアドレスをテスト"""
        long_email = "a" * 100 + "@" + "b" * 100 + ".com"
        # 要件に応じて受け入れるか決定
        result = validate_email(long_email)
        assert isinstance(result, bool)

    def test_special_characters(self):
        """特殊文字をテスト"""
        assert validate_email("user!#$%@example.com") is False

    def test_unicode_characters(self):
        """Unicode文字をテスト"""
        assert validate_email("ユーザー@example.com") is False

テストカバレッジ:100%
実行:pytest tests/test_validators.py -v

テクニック2:境界条件の考慮

シーン: 価格計算関数をテスト

def calculate_discount(price: float, discount_rate: float) -> float:
    """割引後の価格を計算

    Args:
        price: 定価
        discount_rate: 割引率(0-1の間)

    Returns:
        割引後の価格
    """
    if discount_rate < 0 or discount_rate > 1:
        raise ValueError("Discount rate must be between 0 and 1")
    return price * (1 - discount_rate)
あなた:calculate_discountのテストを書いて、
特に境界条件に注意してください

Claude:関数を分析中…

境界条件:
- discount_rate = 0(割引なし)
- discount_rate = 1(完全無料)
- discount_rate < 0(無効)
- discount_rate > 1(無効)
- price = 0
- priceが負の数

テストを作成:

import pytest
from utils.pricing import calculate_discount

class TestCalculateDiscount:
    # 正常ケース
    def test_normal_discount(self):
        assert calculate_discount(100, 0.2) == 80.0
        assert calculate_discount(50, 0.5) == 25.0

    # 境界条件:割引率
    def test_no_discount(self):
        """割引率が0"""
        assert calculate_discount(100, 0) == 100.0

    def test_full_discount(self):
        """割引率が1(完全無料)"""
        assert calculate_discount(100, 1) == 0.0

    def test_invalid_discount_negative(self):
        """割引率が負の数"""
        with pytest.raises(ValueError, match="between 0 and 1"):
            calculate_discount(100, -0.1)

    def test_invalid_discount_over_one(self):
        """割引率が1を超える"""
        with pytest.raises(ValueError, match="between 0 and 1"):
            calculate_discount(100, 1.5)

    # 境界条件:価格
    def test_zero_price(self):
        """価格が0"""
        assert calculate_discount(0, 0.2) == 0.0

    def test_very_small_price(self):
        """非常に小さい価格"""
        result = calculate_discount(0.01, 0.5)
        assert result == pytest.approx(0.005)

    def test_very_large_price(self):
        """非常に大きい価格"""
        result = calculate_discount(1000000, 0.1)
        assert result == 900000.0

    # 精度テスト
    def test_floating_point_precision(self):
        """浮動小数点精度"""
        result = calculate_discount(10.99, 0.15)
        assert result == pytest.approx(9.3415, rel=1e-5)

すべての境界ケースをカバー!

テクニック3:モックデータ生成

シーン: データベースや外部APIが必要な関数をテスト

# services/user_service.py
class UserService:
    def __init__(self, db, email_service):
        self.db = db
        self.email_service = email_service

    def register_user(self, username, email, password):
        # ユーザーが存在するか確認
        if self.db.get_user_by_email(email):
            raise ValueError("Email already registered")

        # ユーザー作成
        user = self.db.create_user(username, email, password)

        # ウェルカムメール送信
        self.email_service.send_welcome_email(user.email)

        return user
あなた:UserService.register_userのテストを書いて、
Mockを使って実際のデータベースとメールサービスへの依存を避けてください

Claude:モックテストを作成:

import pytest
from unittest.mock import Mock, MagicMock
from services.user_service import UserService

class TestUserService:
    @pytest.fixture
    def mock_db(self):
        """モックデータベース"""
        db = Mock()
        db.get_user_by_email = Mock(return_value=None)
        db.create_user = Mock(return_value=MagicMock(
            id=1,
            username="testuser",
            email="test@example.com"
        ))
        return db

    @pytest.fixture
    def mock_email_service(self):
        """モックメールサービス"""
        email_service = Mock()
        email_service.send_welcome_email = Mock()
        return email_service

    @pytest.fixture
    def user_service(self, mock_db, mock_email_service):
        """UserServiceインスタンスを作成"""
        return UserService(mock_db, mock_email_service)

    def test_register_user_success(self, user_service, mock_db, mock_email_service):
        """ユーザー登録成功"""
        # 実行
        user = user_service.register_user("testuser", "test@example.com", "password123")

        # 検証
        assert user.username == "testuser"
        assert user.email == "test@example.com"

        # 呼び出しを検証
        mock_db.get_user_by_email.assert_called_once_with("test@example.com")
        mock_db.create_user.assert_called_once_with("testuser", "test@example.com", "password123")
        mock_email_service.send_welcome_email.assert_called_once_with("test@example.com")

    def test_register_user_email_exists(self, user_service, mock_db):
        """メールアドレスが既に存在"""
        # モックが既存ユーザーを返すよう設定
        mock_db.get_user_by_email.return_value = MagicMock(email="test@example.com")

        # 例外をスローすることを検証
        with pytest.raises(ValueError, match="Email already registered"):
            user_service.register_user("testuser", "test@example.com", "password123")

    def test_register_user_email_not_sent_if_creation_fails(self, user_service, mock_db, mock_email_service):
        """ユーザー作成失敗時にメールを送信しない"""
        # データベース作成失敗を設定
        mock_db.create_user.side_effect = Exception("Database error")

        # 実行して例外をキャッチ
        with pytest.raises(Exception):
            user_service.register_user("testuser", "test@example.com", "password123")

        # メールが送信されていないことを検証
        mock_email_service.send_welcome_email.assert_not_called()

完全なモックテスト、実際の依存関係不要!

コードカバレッジ向上

テクニック4:未カバーパスの分析

シーン: テストカバレッジが60%のみ

あなた:テストカバレッジ分析を実行して、
カバーされていないコードを見つけ、テストを補ってください

pytest --cov=app --cov-report=html

カバレッジレポート:
Name                      Stmts   Miss  Cover
---------------------------------------------
app/services/order.py        50     20    60%

order.pyのカバーされていない部分を分析して、テストを補ってください

Claude:カバレッジレポートを分析中…

htmlcov/index.htmlを確認、カバーされていないコード:

【未カバーパス1】
order.py:45-48(注文キャンセルロジック)
if order.status == "shipped":
    raise ValueError("Cannot cancel shipped order")

理由:テストが発送済み注文のケースをカバーしていない

【未カバーパス2】
order.py:67-70(例外処理)
except DatabaseError as e:
    logger.error(f"Database error: {e}")
    raise

理由:テストがデータベースエラーをシミュレートしていない

【未カバーパス3】
order.py:89-92(返金フロー)
if order.payment_status == "paid":
    process_refund(order.id)

理由:テストが支払い済み注文のキャンセルをカバーしていない

【テスト補充】

def test_cancel_shipped_order():
    """発送済み注文のキャンセルをテスト(失敗すべき)"""
    order = create_order()
    order.status = "shipped"

    with pytest.raises(ValueError, match="Cannot cancel shipped order"):
        order_service.cancel_order(order.id)

def test_database_error_handling(mock_db):
    """データベースエラー処理をテスト"""
    mock_db.get_order.side_effect = DatabaseError("Connection lost")

    with pytest.raises(DatabaseError):
        order_service.get_order(123)

@patch('app.services.order.process_refund')
def test_cancel_paid_order_triggers_refund(mock_refund):
    """支払い済み注文のキャンセルが返金を引き起こすことをテスト"""
    order = create_paid_order()

    order_service.cancel_order(order.id)

    mock_refund.assert_called_once_with(order.id)

これらのテスト追加後…
新しいカバレッジ:95% ✅

テクニック5:非同期コードのテスト

シーン: 非同期関数をテスト

# services/notification_service.py
import asyncio

class NotificationService:
    async def send_notifications(self, user_ids: list[int]):
        tasks = [self.send_notification(uid) for uid in user_ids]
        results = await asyncio.gather(*tasks, return_exceptions=True)
        return results

    async def send_notification(self, user_id: int):
        # 通知送信をシミュレート
        await asyncio.sleep(0.1)
        return f"Notification sent to user {user_id}"
あなた:非同期のNotificationServiceのテストを書いてください

Claude:非同期テストを作成:

import pytest
import asyncio
from services.notification_service import NotificationService

class TestNotificationService:
    @pytest.fixture
    def service(self):
        return NotificationService()

    @pytest.mark.asyncio
    async def test_send_single_notification(self, service):
        """単一通知の送信をテスト"""
        result = await service.send_notification(1)
        assert result == "Notification sent to user 1"

    @pytest.mark.asyncio
    async def test_send_multiple_notifications(self, service):
        """複数通知の並行送信をテスト"""
        user_ids = [1, 2, 3, 4, 5]
        results = await service.send_notifications(user_ids)

        assert len(results) == 5
        for i, result in enumerate(results, 1):
            assert result == f"Notification sent to user {i}"

    @pytest.mark.asyncio
    async def test_send_notifications_with_failure(self, service, monkeypatch):
        """一部の通知が失敗する場合をテスト"""
        async def mock_send(user_id):
            if user_id == 2:
                raise Exception("Send failed")
            return f"Notification sent to user {user_id}"

        monkeypatch.setattr(service, "send_notification", mock_send)

        results = await service.send_notifications([1, 2, 3])

        assert len(results) == 3
        assert results[0] == "Notification sent to user 1"
        assert isinstance(results[1], Exception)
        assert results[2] == "Notification sent to user 3"

    @pytest.mark.asyncio
    async def test_send_notifications_empty_list(self, service):
        """空のユーザーリストをテスト"""
        results = await service.send_notifications([])
        assert results == []

インストール必要:pytest-asyncio
pip install pytest-asyncio

実戦:既存プロジェクトにテストを補う

完全な事例

背景:

  • プロジェクト:EC注文システム
  • 現在のカバレッジ:35%
  • 目標:90%以上

Step 1:現状評価

あなた:プロジェクトのtest/ディレクトリを分析して、
テストカバレッジ不足のモジュールをリストアップし、
優先度順に並べてください

Claude:プロジェクトをスキャン中…

【テストカバレッジ現状】
✓ models/        85% - 良好
✗ services/      25% - 急速に改善が必要
✗ api/           40% - 改善が必要
✓ utils/         90% - 良好

【優先度順】(ビジネス重要度順)

高優先度:
1. services/order_service.py(15%カバレッジ)
   - コアビジネスロジック
   - 金銭取引に関わる

2. services/payment_service.py(20%カバレッジ)
   - 決済フロー
   - 高い耐障害性要求

中優先度:
3. api/orders.py(45%カバレッジ)
   - APIエンドポイント
   - 入力検証が重要

4. services/inventory_service.py(30%カバレッジ)
   - 在庫管理
   - 並行性問題のリスク

低優先度:
5. api/users.py(60%カバレッジ)
   - ある程度カバー済み

推奨:order_service.pyから始める

Step 2:コアモジュールにテストを補う

あなた:services/order_service.pyにテストを補って、
目標カバレッジ90%

Claude:order_service.pyのコードを分析中…

以下の機能にテストが必要:
1. create_order() - 注文作成
2. cancel_order() - 注文キャンセル
3. update_order_status() - ステータス更新
4. calculate_total() - 合計計算
5. validate_order() - 注文検証

tests/services/test_order_service.pyを作成:

[完全なテストスイートを生成、含む:]
- 正常フローテスト
- 異常ケーステスト
- 境界条件テスト
- 並行性安全テスト
- パフォーマンステスト

テストを実行:
pytest tests/services/test_order_service.py --cov=app/services/order_service.py

新しいカバレッジ:92% ✅
未カバー:極端な例外ケースのみ(システムクラッシュなど)

Step 3:統合テストを補う

あなた:ユニットテスト完了後、
統合テストをいくつか追加して、
完全な注文フローをテストしてください

Claude:統合テストを作成:

tests/integration/test_order_flow.py:

import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.database import Base, engine

@pytest.fixture(scope="module")
def test_db():
    """テストデータベース作成"""
    Base.metadata.create_all(bind=engine)
    yield
    Base.metadata.drop_all(bind=engine)

@pytest.fixture
def client():
    return TestClient(app)

@pytest.fixture
def auth_token(client):
    """認証トークン取得"""
    # ユーザー登録
    client.post("/api/auth/register", json={
        "username": "testuser",
        "email": "test@example.com",
        "password": "password123"
    })
    # ログイン
    response = client.post("/api/auth/login", data={
        "username": "testuser",
        "password": "password123"
    })
    return response.json()["access_token"]

class TestOrderFlow:
    def test_complete_order_flow(self, client, auth_token, test_db):
        """完全な注文フローをテスト"""
        headers = {"Authorization": f"Bearer {auth_token}"}

        # 1. 注文作成
        create_response = client.post("/api/orders", json={
            "items": [
                {"product_id": 1, "quantity": 2},
                {"product_id": 2, "quantity": 1}
            ]
        }, headers=headers)
        assert create_response.status_code == 201
        order_id = create_response.json()["id"]

        # 2. 注文照会
        get_response = client.get(f"/api/orders/{order_id}", headers=headers)
        assert get_response.status_code == 200
        assert get_response.json()["status"] == "pending"

        # 3. 支払い確認
        pay_response = client.post(f"/api/orders/{order_id}/pay",
            json={"payment_method": "credit_card"},
            headers=headers
        )
        assert pay_response.status_code == 200

        # 4. ステータス更新確認
        get_response = client.get(f"/api/orders/{order_id}", headers=headers)
        assert get_response.json()["status"] == "paid"

        # 5. 発送
        ship_response = client.post(f"/api/orders/{order_id}/ship",
            headers=headers
        )
        assert ship_response.status_code == 200

        # 6. 注文完了
        complete_response = client.post(f"/api/orders/{order_id}/complete",
            headers=headers
        )
        assert complete_response.status_code == 200

    def test_order_cancellation_flow(self, client, auth_token, test_db):
        """注文キャンセルフローをテスト"""
        # [類似の完全フローテスト]
        pass

統合テストが完全なビジネスフローをカバー!

最終成果:

モジュール 初期カバレッジ 最終カバレッジ 向上
services/ 25% 92% +67%
api/ 40% 88% +48%
全体 35% 90% +55%

テストのベストプラクティス

原則1:AAAパターン

def test_example():
    # Arrange(準備)
    user = User(name="test")

    # Act(実行)
    result = user.get_display_name()

    # Assert(検証)
    assert result == "test"

原則2:1つのテストに1つのアサーション(できるだけ)

# ❌ 良くない
def test_user():
    user = create_user()
    assert user.name == "test"
    assert user.email == "test@example.com"
    assert user.is_active == True

# ✅ 良い
def test_user_name():
    user = create_user()
    assert user.name == "test"

def test_user_email():
    user = create_user()
    assert user.email == "test@example.com"

def test_user_is_active():
    user = create_user()
    assert user.is_active == True

原則3:テスト名は明確に

# ❌ 良くない
def test_1():
    pass

# ✅ 良い
def test_calculate_discount_returns_correct_amount():
    pass

def test_calculate_discount_raises_error_when_negative_price():
    pass

次回予告

次の記事『ドキュメント自動化:AI生成技術ドキュメントのベストプラクティス』では、以下を教えます:

  • AIにAPIドキュメントを生成させる方法
  • README自動生成
  • コードコメント最適化のテクニック
  • 実戦:プロジェクトの完全ドキュメント生成

テストで品質を保証し、ドキュメントで知識を伝える。次回もお楽しみに!


💬 インタラクションタイム

あなたのプロジェクトのテストカバレッジは?

  • A. 90%以上(優秀!)
  • B. 60-90%(なかなか良い)
  • C. 30-60%(改善が必要)
  • D. 30%未満(今すぐ行動!)

小さな課題: Claude Codeにあなたのモジュールの完全なテストを生成させてみましょう!

このブログをフォローして、コード品質を向上させましょう! 🚀


このシリーズについて

これは「AI駆動開発実践」シリーズの第8篇です。今日はAIテストを学びました。次回はAIでドキュメントを生成します!

テストカバレッジ向上を始めましょう。次回もお楽しみに! 👋

Last modified: 2025-11-21

Author