テスト品質ガイドライン
作成日: 2025-12-30 対象: NexusCore プロジェクトの全開発者 目的: 効果的なテスト設計と「テストの為のテスト」の回避
🎯 概要
このドキュメントは、mutation_tester_agent.py のテストリファクタリングから得られた教訓をまとめたものです。良いテストと悪いテストの違いを明確にし、プロジェクト全体のテスト品質を向上させます。
❌ 「テストの為のテスト」とは
定義: 実装のバグを検出できない、形式的なだけのテスト
典型的な兆候
- モックが固定値を返すだけ
# ❌ 悪い例 with patch('subprocess.run', return_value=mock_result): result = agent._run_mutmut() assert result["total"] == 20 # モックが常に20を返す- 実装にバグがあってもテストは通る
- subprocess.run の呼び出しが正しいか検証していない
- 実装の内部詳細をテスト
# ❌ 悪い例 assert subprocess_run.call_count == 2 # 呼び出し回数をテスト- リファクタリングでテストが壊れる
- 「何回呼ぶか」ではなく「正しい結果か」が重要
- mutation testing から除外される
# pyproject.toml tests_dir = [..., "-k", "not (TestRunMutmut or TestIntegration)"]- モックが多すぎて mutmut が動作しない
- テストがコード品質向上に貢献していない
✅ 良いテスト設計
原則
- 外部依存のみモック、ロジックは実際にテスト
- 実装詳細ではなく、振る舞いをテスト
- mutation testing で有効なテスト
良い例 1: パース機能のテスト
# ✅ 良い例: TestParseMutmutOutput
def test_parse_mutmut_output_success(self, mutation_agent):
"""実際のパース機能をテスト(モックなし)"""
output = """
Legend for output:
🎉 Killed mutants.
⠧ 20/20 🎉 17 🙁 3 ⏰ 0 🤔 0
"""
result = mutation_agent._parse_mutmut_output(output)
# 実際のパース結果を検証
assert result["total"] == 20
assert result["killed"] == 17
assert result["survived"] == 3
なぜ良いのか:
- ✅ モックなし、実際のロジックをテスト
- ✅ 実装が変わっても、出力が正しければOK
- ✅ mutation testing で有効
良い例 2: 外部依存のモック
# ✅ 良い例: ConstitutionalCouncilAgent
def test_invoke_llm_with_retry_success(tmp_path, monkeypatch):
agent = ConstitutionalCouncilAgent(...)
# 外部依存(LLM API)のみモック
with patch.object(agent, "execute_llm_task", return_value="test response"):
result = agent._invoke_llm_with_retry("test prompt", retries=2)
# 実際のリトライロジックをテスト
assert result == "test response"
なぜ良いのか:
- ✅ LLM API という外部依存のみモック
- ✅ リトライロジックは実際にテスト
- ✅ 実装が変わっても動作が正しければOK
良い例 3: 実際のファイルI/O
# ✅ 良い例: ConstitutionalCouncilAgent
def test_save_policies_creates_backup(tmp_path, monkeypatch):
policy_path = tmp_path / "policy.json"
agent = ConstitutionalCouncilAgent(policy_path=policy_path)
# 実際のファイルI/O(tmp_path で隔離)
agent._save_policies([{"policy_id": "P-1"}])
# 実際にファイルが作成されたか検証
assert policy_path.exists()
assert json.loads(policy_path.read_text()) == [{"policy_id": "P-1"}]
なぜ良いのか:
- ✅ 実際のファイルシステムを使用(tmp_path で安全)
- ✅ モックではなく実際の動作をテスト
- ✅ ファイルI/Oのバグを検出できる
🔴 悪いテスト設計(削除した例)
悪い例 1: subprocess の完全モック
# ❌ 削除した TestRunMutmut
def test_run_mutmut_success(self, mutation_agent):
mock_result = Mock()
mock_result.stdout = "固定値"
# subprocess.run を完全にモック
with patch('subprocess.run', return_value=mock_result):
result = mutation_agent._run_mutmut(...)
# モックが返した値をそのまま検証
assert result["total"] == 20 # 常に成功
問題点:
- ❌ 実装のバグを検出できない
- ❌ subprocess.run の呼び出しが正しいか不明
- ❌ mutation testing で無効
悪い例 2: 呼び出し回数のテスト
# ❌ 削除した TestIntegration
def test_full_workflow(self):
with patch('subprocess.run', side_effect=[mock1, mock2, mock3]):
result = agent.run_mutation_testing(...)
# 内部実装の詳細をテスト
assert subprocess.run.call_count == 3 # 脆い
問題点:
- ❌ リファクタリングで壊れる
- ❌ 「何回呼ぶか」は実装詳細
- ❌ 「正しい結果を返すか」が重要
📊 mutation_tester_agent リファクタリング結果
Before (40 tests, 10 failing mutmut)
TestRunMutmut (3 tests) # ❌ subprocess呼び出し回数
TestIntegration (2 tests) # ❌ 完全モック化
TestIntegrationReal (5 tests) # ❌ 同上
# pyproject.toml
tests_dir = [..., "-k", "not (TestRunMutmut or TestIntegration or ...)"]
After (30 tests, 0 failing mutmut)
TestParseMutmutOutput (4 tests) # ✅ 実際のパース
TestRunMutationTesting (6 tests) # ✅ 最小限のモック
TestSuggestTestForMutant (8 tests) # ✅ ロジックテスト
# pyproject.toml
tests_dir = [..., "-m", "not slow"] # すべてのテストが有効
改善指標
| 項目 | Before | After | 改善 |
|---|---|---|---|
| テスト数 | 40 | 30 | -25% |
| コード行数 | 1059 | 719 | -32% |
| 実行時間 | 0.47s | 0.40s | -15% |
| 意味のあるテスト | 30 (75%) | 30 (100%) | +33% |
| mutmut除外 | 10テスト | 1テスト | -90% |
🎓 ベストプラクティス
1. モックの使い分け
モックすべきもの:
- ✅ LLM API (execute_llm_task)
- ✅ 外部サービス (HTTP, DB)
- ✅ 時刻 (time.time)
- ✅ GitController
- ✅ ファイルシステム(ただし tmp_path 推奨)
モックすべきでないもの:
- ❌ 自分のコードのロジック
- ❌ パース処理
- ❌ バリデーション
- ❌ データ変換
2. tmp_path の活用
# ✅ 実際のファイルI/Oをテスト
def test_file_operation(tmp_path):
file_path = tmp_path / "test.json"
save_data(file_path, {"key": "value"})
assert file_path.exists()
assert load_data(file_path) == {"key": "value"}
3. parametrize でテストケースを整理
# ✅ 複数のケースを簡潔に
@pytest.mark.parametrize("input,expected", [
("🎉 17 🙁 3", {"killed": 17, "survived": 3}),
("Total: 20, Killed: 17", {"killed": 17}),
("", {"killed": 0, "survived": 0}),
])
def test_parse(input, expected):
result = parse(input)
assert result == expected
4. slow マーカーの活用
# 実際の mutmut 実行など、時間のかかるテスト
@pytest.mark.slow
def test_actual_mutation_testing():
# 30秒以上かかる統合テスト
pass
# 実行
pytest -m "not slow" # 通常は除外
pytest -m "slow" # リリース前に実行
🚀 mutation testing ガイドライン
mutmut の役割
mutation testing はテストの品質を測定します:
- コードに意図的なバグ(mutant)を注入
- テストがバグを検出できるか確認
- 検出できれば「killed」、できなければ「survived」
良い mutation score の条件
- 80%以上のスコア
🎉 17/20 killed (85%) - 実際のバグを検出できるテスト
- モックが少ない
- 実際のロジックをカバー
- mutmut から除外されないテスト
# ✅ 良い設定 tests_dir = ["tests/", "-m", "not slow"] # ❌ 悪い設定 tests_dir = ["tests/", "-k", "not (Test1 or Test2 or ...)"]
mutmut 実行例
# 設定
# pyproject.toml
[tool.mutmut]
paths_to_mutate = ["src/mymodule.py"]
tests_dir = ["tests/test_mymodule.py", "-m", "not slow"]
# 実行
mutmut run --max-children 1
# 結果確認
mutmut results
mutmut show <mutant_id>
📚 他のエージェントの良い例
GuardianAgent (test_guardian_quality_gates.py)
# ✅ 外部依存のみモック
@pytest.fixture(autouse=True)
def disable_llm_router(monkeypatch):
monkeypatch.setattr(base_agent, "LLMRouter", None)
def test_run_quality_gates_all_pass(mock_quality_report):
# 実際の品質ゲート判定ロジックをテスト
result = guardian.run_quality_gates(...)
assert result.tier1_passed is True
ConstitutionalCouncilAgent
# ✅ 実際のファイルI/OとJSON処理
def test_load_policies_existing_file(tmp_path):
policy_path = tmp_path / "policy.json"
policies = [{"policy_id": "P-1"}]
policy_path.write_text(json.dumps(policies))
agent = ConstitutionalCouncilAgent(policy_path=policy_path)
loaded = agent._load_policies()
assert loaded == policies # 実際のパースをテスト
✅ チェックリスト
新しいテストを書く前に確認:
- モックは外部依存のみ(LLM, DB, 時刻など)
- 自分のコードのロジックは実際にテスト
- tmp_path を使って実際のファイルI/Oをテスト
- 実装詳細(呼び出し回数など)ではなく、振る舞いをテスト
- parametrize で複数ケースを効率的に
- 時間のかかるテストには @pytest.mark.slow
- mutation testing で有効なテスト
🔗 関連リソース
📝 更新履歴
- 2025-12-30: 初版作成(mutation_tester_agent リファクタリングの教訓)