チュートリアル

Python実践編:テスト

Pythonテストpytestunittest実践
広告エリア

はじめに

テストはコードの品質を保証し、リファクタリングを安全に行うために不可欠です。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ライブラリ活用編で実践的なライブラリを学びましょう。

広告エリア