はじめに
テストはコードの品質を保証し、リファクタリングを安全に行うために不可欠です。Pythonのテスト手法を学びましょう。
なぜテストを書くのか
- バグの早期発見
- リファクタリング時の安心感
- 仕様のドキュメント化
- 設計の改善(テストしやすいコード = 良い設計)
unittest(標準ライブラリ)
Python標準のテストフレームワークです。
# calculator.py
def add(a, b):
return a + b
def divide(a, b):
if b == 0:
raise ValueError("0で割ることはできません")
return a / b
# test_calculator.py
import unittest
from calculator import add, divide
class TestCalculator(unittest.TestCase):
def test_add(self):
self.assertEqual(add(1, 2), 3)
self.assertEqual(add(-1, 1), 0)
self.assertEqual(add(0, 0), 0)
def test_divide(self):
self.assertEqual(divide(6, 2), 3)
self.assertAlmostEqual(divide(1, 3), 0.333, places=3)
def test_divide_by_zero(self):
with self.assertRaises(ValueError):
divide(1, 0)
if __name__ == "__main__":
unittest.main()
テストの実行
# 単一ファイル
python -m unittest test_calculator.py
# 詳細出力
python -m unittest test_calculator -v
# ディレクトリ内の全テスト
python -m unittest discover
主なアサーションメソッド
self.assertEqual(a, b) # a == b
self.assertNotEqual(a, b) # a != b
self.assertTrue(x) # bool(x) is True
self.assertFalse(x) # bool(x) is False
self.assertIs(a, b) # a is b
self.assertIsNone(x) # x is None
self.assertIn(a, b) # a in b
self.assertIsInstance(a, b) # isinstance(a, b)
self.assertRaises(exc) # 例外が発生するか
self.assertAlmostEqual(a, b) # 浮動小数点の比較
pytest(推奨)
より簡潔で強力なテストフレームワークです。
pip install pytest
# test_calculator_pytest.py
import pytest
from calculator import add, divide
def test_add():
assert add(1, 2) == 3
assert add(-1, 1) == 0
def test_divide():
assert divide(6, 2) == 3
def test_divide_by_zero():
with pytest.raises(ValueError):
divide(1, 0)
テストの実行
# 実行
pytest
# 詳細出力
pytest -v
# 特定のファイル
pytest test_calculator_pytest.py
# 特定のテスト関数
pytest test_calculator_pytest.py::test_add
# 失敗時に停止
pytest -x
# 最後に失敗したテストのみ
pytest --lf
パラメータ化テスト
同じテストを異なる入力で繰り返し実行します。
import pytest
from calculator import add
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(-1, 1, 0),
(0, 0, 0),
(100, 200, 300),
])
def test_add_parametrized(a, b, expected):
assert add(a, b) == expected
フィクスチャ
テストのセットアップとクリーンアップを行います。
import pytest
@pytest.fixture
def sample_list():
"""テスト用のリストを提供"""
return [1, 2, 3, 4, 5]
def test_list_length(sample_list):
assert len(sample_list) == 5
def test_list_sum(sample_list):
assert sum(sample_list) == 15
スコープ付きフィクスチャ
import pytest
@pytest.fixture(scope="module")
def database_connection():
"""モジュール単位で1回だけ実行"""
print("DBに接続")
conn = {"connected": True}
yield conn # テストに値を渡す
print("DB切断") # クリーンアップ
def test_query1(database_connection):
assert database_connection["connected"]
def test_query2(database_connection):
assert database_connection["connected"]
組み込みフィクスチャ
def test_temp_file(tmp_path):
"""一時ディレクトリを使用"""
file = tmp_path / "test.txt"
file.write_text("hello")
assert file.read_text() == "hello"
def test_capture_output(capsys):
"""標準出力をキャプチャ"""
print("hello")
captured = capsys.readouterr()
assert captured.out == "hello\n"
def test_monkeypatch(monkeypatch):
"""環境変数を一時的に変更"""
monkeypatch.setenv("API_KEY", "test-key")
import os
assert os.environ["API_KEY"] == "test-key"
モック
外部依存をシミュレートします。
from unittest.mock import Mock, patch, MagicMock
import requests
# テスト対象
def fetch_user(user_id):
response = requests.get(f"https://api.example.com/users/{user_id}")
return response.json()
# テスト
def test_fetch_user():
with patch("requests.get") as mock_get:
mock_get.return_value.json.return_value = {"id": 1, "name": "Taro"}
result = fetch_user(1)
assert result["name"] == "Taro"
mock_get.assert_called_once_with("https://api.example.com/users/1")
Mockオブジェクト
from unittest.mock import Mock
# 基本的な使い方
mock = Mock()
mock.some_method.return_value = 42
assert mock.some_method() == 42
# 呼び出しの検証
mock.some_method(1, 2, key="value")
mock.some_method.assert_called_with(1, 2, key="value")
mock.some_method.assert_called_once()
# 例外を発生させる
mock.error_method.side_effect = ValueError("エラー")
クラスのモック
from unittest.mock import patch
class EmailService:
def send(self, to, subject, body):
# 実際にメール送信
pass
class UserService:
def __init__(self, email_service):
self.email_service = email_service
def register(self, email):
# ユーザー登録処理
self.email_service.send(email, "Welcome", "登録完了")
return True
def test_register():
mock_email = Mock()
service = UserService(mock_email)
result = service.register("test@example.com")
assert result is True
mock_email.send.assert_called_once_with(
"test@example.com", "Welcome", "登録完了"
)
テストカバレッジ
テストがどの程度コードを網羅しているか測定します。
pip install pytest-cov
# カバレッジ計測
pytest --cov=myproject
# HTMLレポート生成
pytest --cov=myproject --cov-report=html
テスト駆動開発(TDD)
テストを先に書き、それを満たすコードを実装する手法です。
# 1. 失敗するテストを書く(Red)
def test_is_palindrome():
assert is_palindrome("radar") is True
assert is_palindrome("hello") is False
assert is_palindrome("A man a plan a canal Panama") is True
# 2. テストを通す最小限のコードを書く(Green)
def is_palindrome(s):
s = s.lower().replace(" ", "")
return s == s[::-1]
# 3. リファクタリングする(Refactor)
import re
def is_palindrome(s):
cleaned = re.sub(r'[^a-z0-9]', '', s.lower())
return cleaned == cleaned[::-1]
conftest.py
共通のフィクスチャを定義するファイルです。
# conftest.py
import pytest
@pytest.fixture
def api_client():
"""全テストで使える共通フィクスチャ"""
return {"base_url": "https://api.example.com"}
@pytest.fixture(autouse=True)
def reset_database():
"""各テスト前に自動実行"""
print("DBリセット")
yield
print("クリーンアップ")
プロジェクト構成例
my_project/
├── src/
│ └── my_project/
│ ├── __init__.py
│ ├── calculator.py
│ └── user_service.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_calculator.py
│ └── test_user_service.py
├── pyproject.toml
└── pytest.ini
# pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
addopts = -v --tb=short
実践例:ユーザー登録のテスト
# user_service.py
class UserRepository:
def save(self, user):
raise NotImplementedError
class EmailService:
def send_welcome(self, email):
raise NotImplementedError
class UserService:
def __init__(self, repo, email):
self.repo = repo
self.email = email
def register(self, name, email):
if not name or not email:
raise ValueError("名前とメールは必須です")
if "@" not in email:
raise ValueError("無効なメールアドレス")
user = {"name": name, "email": email}
self.repo.save(user)
self.email.send_welcome(email)
return user
# test_user_service.py
import pytest
from unittest.mock import Mock
from user_service import UserService
@pytest.fixture
def mock_repo():
return Mock()
@pytest.fixture
def mock_email():
return Mock()
@pytest.fixture
def service(mock_repo, mock_email):
return UserService(mock_repo, mock_email)
def test_register_success(service, mock_repo, mock_email):
result = service.register("太郎", "taro@example.com")
assert result["name"] == "太郎"
mock_repo.save.assert_called_once()
mock_email.send_welcome.assert_called_once_with("taro@example.com")
def test_register_empty_name(service):
with pytest.raises(ValueError, match="必須"):
service.register("", "test@example.com")
def test_register_invalid_email(service):
with pytest.raises(ValueError, match="無効"):
service.register("太郎", "invalid-email")
まとめ
unittestは標準ライブラリ、pytestはより簡潔で強力- パラメータ化テストで複数ケースを効率的にテスト
- フィクスチャでセットアップ・クリーンアップを共通化
- モックで外部依存を分離し、単体テストを可能に
- TDDでテストファーストな開発を実践
- カバレッジでテストの網羅性を確認
これでPython実践編は完了です。次はPythonライブラリ活用編で実践的なライブラリを学びましょう。