| name | discord-bot-test |
| description | Discord Bot(TalkBot2)のテストコード作成をサポート。テスト駆動開発(TDD)を実践し、モックを使用した単体・統合テストのベストプラクティスを提供 |
| compatibility | Designed for GitHub Copilot CLI |
| metadata | {"author":"TalkBot2 Project","version":"1.0","original-source":"https://github.com/syuutaMC"} |
Discord Bot Testing Skill
このスキルは、TalkBot2(Discord Bot)のテストコード作成をサポートし、**テスト駆動開発(TDD)**を実践するためのガイドラインを提供します。
スキルの目的
高品質で保守性の高いDiscord Botを開発するため、以下を実現します:
- ✅ テストファースト原則の実践
- ✅ モックを活用した外部依存の分離
- ✅ 単体テストと統合テストの適切な使い分け
- ✅ 高いテストカバレッジの維持
テスト作成の原則
1. テストファースト原則
新機能を実装する前に、まずテストを書く
async def new_feature():
pass
@pytest.mark.asyncio
async def test_new_feature_success():
bot = create_mock_bot()
result = await bot.new_feature()
assert result is not None
適用場面:
- 新機能追加時
- リファクタリング時(既存テストの更新)
- バグ修正時(バグを再現するテストを先に書く)
2. テストの種類と使い分け
単体テスト(Unit Tests):
- 目的: 個別の関数・メソッドを検証
- 特徴: 外部依存はすべてモック化、高速実行
- 配置:
tests/test_*.py
統合テスト(Integration Tests):
- 目的: モジュール間の連携を検証
- 特徴: 実際の動作フローに近い、モックは最小限
- 配置:
tests/integration/test_*.py
3. AAA (Arrange-Act-Assert) パターン
@pytest.mark.asyncio
async def test_create_audio_with_custom_speaker():
client = VoicevoxClient()
client.session = AsyncMock()
mock_response = create_mock_audio_response(b"audio_data")
client.session.post.return_value.__aenter__.return_value = mock_response
result = await client.create_audio("テスト", speaker_id=3, speed=1.2)
assert result == b"audio_data"
client.session.post.assert_called_once()
Discord Bot特有のテストパターン
1. スラッシュコマンドのテスト
import discord
from discord import app_commands
from unittest.mock import AsyncMock, MagicMock
import pytest
@pytest.mark.asyncio
async def test_slash_command_join_voice_channel():
"""ボイスチャンネル参加コマンドのテスト"""
bot = MagicMock(spec=VoiceBot)
interaction = MagicMock(spec=discord.Interaction)
voice_channel = MagicMock(spec=discord.VoiceChannel)
interaction.user.voice.channel = voice_channel
interaction.response.send_message = AsyncMock()
interaction.guild.voice_client = None
voice_channel.connect = AsyncMock()
await join_command(interaction, bot)
voice_channel.connect.assert_called_once()
interaction.response.send_message.assert_called_once()
assert "参加しました" in str(interaction.response.send_message.call_args)
@pytest.mark.asyncio
async def test_slash_command_user_not_in_voice():
"""ユーザーがボイスチャンネルにいない場合のエラー処理"""
bot = MagicMock(spec=VoiceBot)
interaction = MagicMock(spec=discord.Interaction)
interaction.user.voice = None
interaction.response.send_message = AsyncMock()
await join_command(interaction, bot)
interaction.response.send_message.assert_called_once()
call_args = str(interaction.response.send_message.call_args)
assert "ボイスチャンネルに参加してください" in call_args or "エラー" in call_args
2. 音声再生のテスト
@pytest.mark.asyncio
async def test_voice_playback_with_queue():
"""音声キューを使用した再生のテスト"""
bot = VoiceBot()
guild_id = 12345
bot.voice_queues[guild_id] = asyncio.Queue()
voice_client = MagicMock(spec=discord.VoiceClient)
voice_client.is_playing.return_value = False
voice_client.play = MagicMock()
audio_source = MagicMock(spec=discord.FFmpegPCMAudio)
await bot.voice_queues[guild_id].put(audio_source)
await bot.process_voice_queue(guild_id, voice_client)
voice_client.play.assert_called_once()
assert bot.voice_queues[guild_id].empty()
3. メッセージ読み上げのテスト
@pytest.mark.asyncio
async def test_on_message_text_to_speech():
"""メッセージを受信して読み上げる処理のテスト"""
bot = VoiceBot()
bot.voicevox = AsyncMock(spec=VoicevoxClient)
bot.voicevox.create_audio = AsyncMock(return_value=b"audio_data")
message = MagicMock(spec=discord.Message)
message.author.bot = False
message.content = "こんにちは"
message.guild.id = 12345
message.author.id = 67890
voice_client = MagicMock(spec=discord.VoiceClient)
message.guild.voice_client = voice_client
await bot.on_message(message)
bot.voicevox.create_audio.assert_called_once_with(
"こんにちは",
speaker_id=bot.user_speakers.get(67890, 1),
speed=bot.user_speeds.get(67890, 1.0)
)
VOICEVOX連携のテストパターン
1. APIクライアントのモック
import aiohttp
from unittest.mock import AsyncMock, patch
import pytest
@pytest.fixture
async def voicevox_client():
"""VOICEVOX Clientのフィクスチャ"""
client = VoicevoxClient("http://localhost:50021")
await client.initialize()
yield client
await client.close()
@pytest.mark.asyncio
async def test_get_speakers_success(voicevox_client):
"""話者一覧取得の成功ケース"""
expected_speakers = [
{"name": "四国めたん", "speaker_uuid": "uuid1"},
{"name": "ずんだもん", "speaker_uuid": "uuid2"}
]
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value=expected_speakers)
voicevox_client.session.get = AsyncMock(
return_value=AsyncMockContext(mock_response)
)
speakers = await voicevox_client.get_speakers()
assert len(speakers) == 2
assert speakers[0]["name"] == "四国めたん"
voicevox_client.session.get.assert_called_once_with(
"http://localhost:50021/speakers"
)
@pytest.mark.asyncio
async def test_create_audio_api_error(voicevox_client):
"""音声生成APIエラー時の処理"""
mock_response = AsyncMock()
mock_response.status = 500
voicevox_client.session.post = AsyncMock(
return_value=AsyncMockContext(mock_response)
)
result = await voicevox_client.create_audio("テスト", speaker_id=1)
assert result is None
@pytest.mark.asyncio
async def test_create_audio_timeout():
"""タイムアウト時の処理"""
client = VoicevoxClient()
client.session = AsyncMock()
client.session.post.side_effect = asyncio.TimeoutError()
result = await client.create_audio("テスト", speaker_id=1)
assert result is None
2. AsyncMock用コンテキストマネージャー
class AsyncMockContext:
"""aiohttpのレスポンスコンテキストマネージャーのモック"""
def __init__(self, response):
self.response = response
async def __aenter__(self):
return self.response
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
設定ファイルのテスト
import json
import tempfile
from pathlib import Path
def test_load_config_file():
"""設定ファイル読み込みのテスト"""
with tempfile.TemporaryDirectory() as tmpdir:
config_path = Path(tmpdir) / "config.json"
config_data = {
"user_speakers": {"12345": 3, "67890": 5},
"user_speeds": {"12345": 1.2}
}
config_path.write_text(json.dumps(config_data), encoding="utf-8")
bot = VoiceBot()
bot.CONFIG_FILE = config_path
bot._load_config()
assert bot.user_speakers[12345] == 3
assert bot.user_speeds[12345] == 1.2
def test_save_config_file():
"""設定ファイル保存のテスト"""
with tempfile.TemporaryDirectory() as tmpdir:
config_path = Path(tmpdir) / "config.json"
bot = VoiceBot()
bot.CONFIG_FILE = config_path
bot.user_speakers = {12345: 3}
bot.user_speeds = {12345: 1.5}
bot._save_config()
assert config_path.exists()
saved_data = json.loads(config_path.read_text(encoding="utf-8"))
assert saved_data["user_speakers"]["12345"] == 3
assert saved_data["user_speeds"]["12345"] == 1.5
テスト実行環境の構築
pytest設定 (pytest.ini)
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
asyncio_mode = auto
markers =
asyncio: mark test as async
slow: mark test as slow running
integration: mark test as integration test
依存パッケージ (requirements-dev.txt)
pytest>=7.4.0
pytest-asyncio>=0.21.0
pytest-cov>=4.1.0
pytest-mock>=3.11.0
テスト実行コマンド
pytest
pytest --cov=src --cov-report=html
pytest tests/test_bot.py
pytest -v
pytest --lf
テスト実行フロー(チェックリスト)
重要なガイドライン
✅ 必ず守るべきルール
-
テストコードとプロダクションコードはセット
- ✅ 新機能実装時は必ずテストも作成
- ✅ バグ修正時は再現テストを先に書く
- ✅ リファクタリング前にテストが通ることを確認
-
外部依存は必ずモック化
- ✅ Discord API(interaction, message, voice_clientなど)
- ✅ VOICEVOX API(create_audio, get_speakersなど)
- ✅ ファイルシステム(設定ファイル読み書き)
-
テストは独立して実行可能
- ✅ テストの実行順序に依存しない
- ✅ 他のテストの結果に影響されない
- ✅ データベースやファイルの状態をクリーンアップ
-
命名規則を守る
def test_<機能名>_<条件>_<期待結果>():
pass
def test_join_voice_channel_when_user_in_channel_succeeds():
pass
def test_create_audio_when_api_timeout_returns_none():
pass
-
適切なカバレッジを維持
- 全体: 80%以上
- コアロジック(bot.py, voicevox_client.py): 90%以上
- 新規追加コード: 100%
📋 テストの分類基準
単体テスト(Unit Tests):
- 個別の関数・メソッド
- すべての外部依存をモック
- 高速実行(ミリ秒単位)
統合テスト(Integration Tests):
- 複数モジュールの連携
- 主要な依存はモック、内部連携は実コード
- やや時間がかかる(秒単位)
🎯 TalkBot2固有のベストプラクティス
-
Discord Bot関連のテスト:
interaction = MagicMock(spec=discord.Interaction)
interaction.user.voice.channel = MagicMock(spec=discord.VoiceChannel)
interaction = MagicMock()
interaction.user = MagicMock()
-
VOICEVOX連携のテスト:
mock_response = AsyncMock()
mock_response.status = 200
client.session.get.return_value = AsyncMockContext(mock_response)
mock_response = AsyncMock()
client.session.get.return_value = mock_response
-
非同期処理のテスト:
@pytest.mark.asyncio
async def test_async_function():
result = await async_function()
assert result is not None
def test_async_function():
result = async_function()
assert result is not None
テスト環境の構築
依存パッケージ(requirements-dev.txt)
# テストフレームワーク
pytest>=7.4.0
pytest-asyncio>=0.21.0
pytest-cov>=4.1.0
pytest-mock>=3.11.0
# カバレッジレポート
coverage>=7.3.0
pytest設定(pytest.ini)
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
asyncio_mode = auto
markers =
asyncio: mark test as async
slow: mark test as slow running
integration: mark test as integration test
unit: mark test as unit test
テスト実行コマンド
# すべてのテストを実行
pytest
# カバレッジ付きで実行
pytest --cov=src --cov-report=html --cov-report=term
# 特定のテストファイルのみ
pytest tests/test_bot.py
# 詳細出力
pytest -v
# 失敗したテストのみ再実行
pytest --lf
# マーカーでフィルタリング
pytest -m "not slow"
Copilotへの指示
新機能実装時
必ず以下を同時に生成:
- 機能コード(src/配下)
- テストコード(tests/配下)
- モックの適切な使用
- 正常系・異常系・境界値のテストケース
テンプレート:
@pytest.mark.asyncio
async def test_new_feature_success():
"""機能が正常に動作することを確認"""
...
...
...
async def new_feature():
"""新機能の実装"""
...
リファクタリング時
- 既存テストを実行して現在の動作を確認
- リファクタリング実施
- テストが全て通ることを確認
- 必要に応じてテストも更新
コードレビュー時のチェックポイント
カバレッジ目標
| カテゴリ | 目標カバレッジ | 対象ファイル |
|---|
| コアロジック | 90%以上 | bot.py, voicevox_client.py |
| ユーティリティ | 80%以上 | その他のsrc/配下 |
| 全体 | 80%以上 | プロジェクト全体 |
| 新規追加コード | 100% | 新しく追加した機能 |
よくあるパターンと解決策
パターン1: Discord Interaction のモック
interaction = MagicMock(spec=discord.Interaction)
interaction.guild = MagicMock(spec=discord.Guild)
interaction.guild.id = 12345
interaction.user = MagicMock(spec=discord.Member)
interaction.user.voice.channel = MagicMock(spec=discord.VoiceChannel)
interaction.response.send_message = AsyncMock()
パターン2: aiohttpレスポンスのモック
class AsyncMockContext:
def __init__(self, response):
self.response = response
async def __aenter__(self):
return self.response
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value=[...])
client.session.get.return_value = AsyncMockContext(mock_response)
パターン3: 音声ファイルの生成テスト
import tempfile
from pathlib import Path
def test_save_audio_file():
with tempfile.TemporaryDirectory() as tmpdir:
audio_path = Path(tmpdir) / "test.wav"
save_audio(b"audio_data", audio_path)
assert audio_path.exists()
assert audio_path.read_bytes() == b"audio_data"
エラー対応
テストが失敗した場合
# 詳細なエラー情報を表示
pytest -vv
# 最後に失敗したテストのみ再実行
pytest --lf
# ステップごとにデバッグ
pytest --pdb
# 特定のテストのみ実行
pytest tests/test_bot.py::test_specific_function
モックが期待通りに動作しない場合
mock_object.method.assert_called()
print(mock_object.method.call_args)
print(mock_object.method.call_args_list)
import pprint
pprint.pprint(mock_object.method.call_args_list)
まとめ
このスキルを活用することで、TalkBot2プロジェクトは以下を実現します:
✅ 高品質なコードベース
✅ 安心してリファクタリング可能
✅ 新規開発者のオンボーディング容易化
- テストコードがドキュメントの役割
- 期待動作の明確化
✅ 継続的な品質向上
すべての変更は、テストとともに。