Pythonにおけるユニットテスト:例を交えた完全ガイド

pytest を用いた Python のテスト、TDD、モック、およびカバレッジ

目次

ユニットテストは、Pythonコードが正しく動作し、プロジェクトが進化してもその動作が維持されることを保証します。 この包括的なガイドでは、Pythonでのユニットテストについて知っておくべきすべての内容をカバーしており、基本的な概念から高度な技術まで説明しています。

Python Unit Testing

ユニットテストがなぜ重要なのか

ユニットテストはPython開発者にとって多くの利点をもたらします:

  • 早期のバグ検出:バグが本番環境に到達する前に発見できます
  • コード品質:モジュール的でテスト可能なコードを書くことを強制します
  • リファクタリングの信頼性:変更を安全に行えるように、テストがリグレッションを検出します
  • ドキュメント:テストはコードがどのように動作するかの実行可能なドキュメントとなります
  • 開発のスピード:自動テストは手動テストよりも速く実行できます
  • 設計の改善:テスト可能なコードを書くことは、より良いアーキテクチャにつながります

ユニットテストの基本概念の理解

ユニットテストとは何か?

ユニットテストは、アプリケーションの最もテスト可能な部分(通常は関数またはメソッド)を孤立して検証します。それは次の特性を持つべきです:

  • 高速:ミリ秒単位で実行される
  • 孤立:他のテストや外部システムに依存しない
  • 再現性:毎回同じ結果を出す
  • 自己検証:手動検査なしに明確にパスまたは失敗する
  • タイムリー:コードの作成前または作成中に書かれる

テストピラミッド

健全なテストスイートはテストピラミッドに従います:

           /\
          /  \     E2Eテスト(少数)
         /____\
        /      \   インテグレーションテスト(いくつか)
       /_____ ___\
      /          \ ユニットテスト(多数)
     /_____ __ ___\

ユニットテストは基礎となるものであり、多数存在し、高速で、迅速なフィードバックを提供します。

Pythonテストフレームワークの比較

unittest:組み込みフレームワーク

Pythonの標準ライブラリにはJUnitにインスパイアされたunittestが含まれています:

import unittest

class TestCalculator(unittest.TestCase):
    def setUp(self):
        """各テストの前に実行"""
        self.calc = Calculator()
    
    def tearDown(self):
        """各テストの後に実行"""
        self.calc = None
    
    def test_addition(self):
        result = self.calc.add(2, 3)
        self.assertEqual(result, 5)
    
    def test_division_by_zero(self):
        with self.assertRaises(ZeroDivisionError):
            self.calc.divide(10, 0)

if __name__ == '__main__':
    unittest.main()

長所:

  • 組み込みで、インストール不要
  • xUnitフレームワークに精通した開発者にとって良い
  • 企業向けに、確立されている

短所:

  • ブーリアンの冗長な構文
  • テストの組織化にクラスが必要
  • フィクスチャ管理が柔軟性に欠ける

pytest:現代的な選択肢

pytestは最も人気のあるサードパーティのテストフレームワークです:

import pytest

def test_addition():
    calc = Calculator()
    assert calc.add(2, 3) == 5

def test_division_by_zero():
    calc = Calculator()
    with pytest.raises(ZeroDivisionError):
        calc.divide(10, 0)

@pytest.mark.parametrize("a,b,expected", [
    (2, 3, 5),
    (-1, 1, 0),
    (0, 0, 0),
])
def test_addition_parametrized(a, b, expected):
    calc = Calculator()
    assert calc.add(a, b) == expected

長所:

  • 簡潔でPython的な構文
  • 強力なフィクスチャシステム
  • 優れたプラグインエコシステム
  • より良いエラーレポート
  • パラメータ化テストが組み込まれている

短所:

  • インストールが必要
  • 他の言語の開発者にはなじみがない

インストール:

pip install pytest pytest-cov pytest-mock

あなたの最初のユニットテストを書く

Test-Driven Development(TDD)を使ってから始める簡単な例を構築しましょう。Pythonに初めて触れるか、構文や言語機能のクイックリファレンスが必要な場合は、Pythonチートシートを確認してください。Pythonの基本を包括的に概観できます。

例:文字列ユーティリティ関数

ステップ1:テストをまず書く(赤)

test_string_utils.pyを作成します:

import pytest
from string_utils import reverse_string, is_palindrome, count_vowels

def test_reverse_string():
    assert reverse_string("hello") == "olleh"
    assert reverse_string("") == ""
    assert reverse_string("a") == "a"

def test_is_palindrome():
    assert is_palindrome("racecar") == True
    assert is_palindrome("hello") == False
    assert is_palindrome("") == True
    assert is_palindrome("A man a plan a canal Panama") == True

def test_count_vowels():
    assert count_vowels("hello") == 2
    assert count_vowels("HELLO") == 2
    assert count_vowels("xyz") == 0
    assert count_vowels("") == 0

ステップ2:テストを通過させるための最小限のコードを書く(緑)

string_utils.pyを作成します:

def reverse_string(s: str) -> str:
    """文字列を逆順にします"""
    return s[::-1]

def is_palindrome(s: str) -> bool:
    """文字列が回文かどうかをチェックします(大文字小文字やスペースを無視)"""
    cleaned = ''.join(s.lower().split())
    return cleaned == cleaned[::-1]

def count_vowels(s: str) -> int:
    """文字列内の母音の数をカウントします"""
    return sum(1 for char in s.lower() if char in 'aeiou')

ステップ3:テストを実行する

pytest test_string_utils.py -v

出力:

test_string_utils.py::test_reverse_string PASSED
test_string_utils.py::test_is_palindrome PASSED
test_string_utils.py::test_count_vowels PASSED

高度なテスト技術

フィクスチャを使用したテストセットアップ

フィクスチャは再利用可能なテストセットアップとテアダウンを提供します:

import pytest
from database import Database

@pytest.fixture
def db():
    """テスト用データベースを作成します"""
    database = Database(":memory:")
    database.create_tables()
    yield database  # テストに提供
    database.close()  # テスト後のクリーンアップ

@pytest.fixture
def sample_users(db):
    """データベースにサンプルユーザーを追加します"""
    db.add_user("Alice", "alice@example.com")
    db.add_user("Bob", "bob@example.com")
    return db

def test_get_user(sample_users):
    user = sample_users.get_user_by_email("alice@example.com")
    assert user.name == "Alice"

def test_user_count(sample_users):
    assert sample_users.count_users() == 2

フィクスチャスコープ

スコープでフィクスチャのライフタイムを制御します:

@pytest.fixture(scope="function")  # デフォルト:各テストごとに実行
def func_fixture():
    return create_resource()

@pytest.fixture(scope="class")  # 各テストクラスごとに1回
def class_fixture():
    return create_resource()

@pytest.fixture(scope="module")  # 各モジュールごとに1回
def module_fixture():
    return create_resource()

@pytest.fixture(scope="session")  # 各テストセッションごとに1回
def session_fixture():
    return create_expensive_resource()

外部依存をモックする

外部依存からコードを孤立させるためにモックを使用します:

from unittest.mock import Mock, patch, MagicMock
import requests

class WeatherService:
    def get_temperature(self, city):
        response = requests.get(f"https://api.weather.com/{city}")
        return response.json()["temp"]

# テストでモックを使用
def test_get_temperature():
    service = WeatherService()
    
    # requests.get関数をモック
    with patch('requests.get') as mock_get:
        # モックレスポンスを設定
        mock_response = Mock()
        mock_response.json.return_value = {"temp": 72}
        mock_get.return_value = mock_response
        
        # テスト
        temp = service.get_temperature("Boston")
        assert temp == 72
        
        # 呼び出しを確認
        mock_get.assert_called_once_with("https://api.weather.com/Boston")

pytest-mockプラグインを使用する

pytest-mockはより洗練された構文を提供します:

def test_get_temperature(mocker):
    service = WeatherService()
    
    # pytest-mockを使用してモック
    mock_response = mocker.Mock()
    mock_response.json.return_value = {"temp": 72}
    mocker.patch('requests.get', return_value=mock_response)
    
    temp = service.get_temperature("Boston")
    assert temp == 72

パラメータ化されたテスト

複数のシナリオを効率的にテストします:

import pytest

@pytest.mark.parametrize("input,expected", [
    ("", True),
    ("a", True),
    ("ab", False),
    ("aba", True),
    ("racecar", True),
    ("hello", False),
])
def test_is_palindrome_parametrized(input, expected):
    assert is_palindrome(input) == expected

@pytest.mark.parametrize("number,is_even", [
    (0, True),
    (1, False),
    (2, True),
    (-1, False),
    (-2, True),
])
def test_is_even(number, is_even):
    assert (number % 2 == 0) == is_even

例外とエラーハンドリングのテスト

import pytest

def divide(a, b):
    if b == 0:
        raise ValueError("ゼロで割ることはできません")
    return a / b

def test_divide_by_zero():
    with pytest.raises(ValueError, match="ゼロで割ることはできません"):
        divide(10, 0)

def test_divide_by_zero_with_message():
    with pytest.raises(ValueError) as exc_info:
        divide(10, 0)
    assert "zero" in str(exc_info.value).lower()

# 例外が発生しないことをテスト
def test_divide_success():
    result = divide(10, 2)
    assert result == 5.0

非同期コードのテスト

非同期コードのテストは、現代のPythonアプリケーションにとって不可欠です、特にAPI、データベース、AIサービスを使用する場合。非同期関数をテストする方法は以下の通りです:

import pytest
import asyncio

async def fetch_data(url):
    """非同期関数でデータを取得します"""
    await asyncio.sleep(0.1)  # API呼び出しをシミュレート
    return {"status": "success"}

@pytest.mark.asyncio
async def test_fetch_data():
    result = await fetch_data("https://api.example.com")
    assert result["status"] == "success"

@pytest.mark.asyncio
async def test_fetch_data_with_mock(mocker):
    # 非同期関数をモック
    mock_fetch = mocker.AsyncMock(return_value={"status": "mocked"})
    mocker.patch('module.fetch_data', mock_fetch)
    
    result = await fetch_data("https://api.example.com")
    assert result["status"] == "mocked"

AIサービスと統合する際の実用的な非同期コードテスト例については、OllamaとPythonの統合を参照してください。LLMの相互作用のためのテスト戦略が含まれています。

コードカバレッジ

テストされているコードの割合を測定します:

pytest-covを使用する

# テストを実行してカバレッジを取得
pytest --cov=myproject tests/

# HTMLレポートを生成
pytest --cov=myproject --cov-report=html tests/

# 欠落している行を表示
pytest --cov=myproject --cov-report=term-missing tests/

カバレッジ設定

.coveragercを作成します:

[run]
source = myproject
omit =
    */tests/*
    */venv/*
    */__pycache__/*

[report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise AssertionError
    raise NotImplementedError
    if __name__ == .__main__.:
    if TYPE_CHECKING:
    @abstractmethod

カバレッジのベストプラクティス

  1. 重要なコードパスでは80%以上のカバレッジを目指す
  2. 100%を目指さない - 意味のあるテストに注力
  3. エッジケースをテスト - ただハッピーなパスだけではなく
  4. カバレッジレポートからボイラープレートを除外
  5. カバレッジをガイドとして、目標として扱わない

テストの組織とプロジェクト構造

推奨構造

myproject/
├── myproject/
│   ├── __init__.py
│   ├── module1.py
│   ├── module2.py
│   └── utils.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py          # 共有フィクスチャ
│   ├── test_module1.py
│   ├── test_module2.py
│   ├── test_utils.py
│   └── integration/
│       ├── __init__.py
│       └── test_integration.py
├── pytest.ini
├── requirements.txt
└── requirements-dev.txt

conftest.pyで共有フィクスチャを使用する

# tests/conftest.py
import pytest
from myproject.database import Database

@pytest.fixture(scope="session")
def test_db():
    """セッション全体にわたるテストデータベース"""
    db = Database(":memory:")
    db.create_schema()
    yield db
    db.close()

@pytest.fixture
def clean_db(test_db):
    """各テスト用にクリーンなデータベース"""
    test_db.clear_all_tables()
    return test_db

pytest.ini設定

[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
    -v
    --strict-markers
    --cov=myproject
    --cov-report=term-missing
    --cov-report=html
markers =
    slow: テストが遅いことを示します
    integration: インテグレーションテストであることを示します
    unit: ユニットテストであることを示します

ユニットテストのベストプラクティス

1. AAAパターンに従う

Arrange-Act-Assertはテストを明確にします:

def test_user_creation():
    # Arrange
    username = "john_doe"
    email = "john@example.com"
    
    # Act
    user = User(username, email)
    
    # Assert
    assert user.username == username
    assert user.email == email
    assert user.is_active == True

2. 1つのテストに1つのアサーション(ガイドライン、ルールではありません)

# 良い:焦点を当てたテスト
def test_user_username():
    user = User("john_doe", "john@example.com")
    assert user.username == "john_doe"

def test_user_email():
    user = User("john_doe", "john@example.com")
    assert user.email == "john@example.com"

# また許容される:関連するアサーション
def test_user_creation():
    user = User("john_doe", "john@example.com")
    assert user.username == "john_doe"
    assert user.email == "john@example.com"
    assert isinstance(user.created_at, datetime)

3. 説明的なテスト名を使用する

# バッド
def test_user():
    pass

# グッド
def test_user_creation_with_valid_data():
    pass

def test_user_creation_fails_with_invalid_email():
    pass

def test_user_password_is_hashed_after_setting():
    pass

4. エッジケースと境界をテストする

def test_age_validation():
    # 有効なケース
    assert validate_age(0) == True
    assert validate_age(18) == True
    assert validate_age(120) == True
    
    # 境界ケース
    assert validate_age(-1) == False
    assert validate_age(121) == False
    
    # エッジケース
    with pytest.raises(TypeError):
        validate_age("18")
    with pytest.raises(TypeError):
        validate_age(None)

5. テストを独立させること

# バッド:テストが順序に依存
counter = 0

def test_increment():
    global counter
    counter += 1
    assert counter == 1

def test_increment_again():  # 単独で実行すると失敗
    global counter
    counter += 1
    assert counter == 2

# グッド:テストが独立
def test_increment():
    counter = Counter()
    counter.increment()
    assert counter.value == 1

def test_increment_multiple_times():
    counter = Counter()
    counter.increment()
    counter.increment()
    assert counter.value == 2

6. 実装詳細をテストしないこと

# バッド:実装をテスト
def test_sort_uses_quicksort():
    sorter = Sorter()
    assert sorter.algorithm == "quicksort"

# グッド:動作をテスト
def test_sort_returns_sorted_list():
    sorter = Sorter()
    result = sorter.sort([3, 1, 2])
    assert result == [1, 2, 3]

7. 実際の使用ケースをテストすること

ライブラリがデータを処理または変換する場合、実際の使用ケースに焦点を当ててください。例えば、ウェブスクレイピングやコンテンツ変換を処理している場合、HTMLをMarkdownに変換するPythonガイドを参照してください。これは、さまざまな変換ライブラリのためのテスト戦略とベンチマーク比較が含まれています。

連続インテグレーションの統合

GitHub Actionsの例

# .github/workflows/tests.yml
name: テスト

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [3.9, 3.10, 3.11, 3.12]
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Python ${{ matrix.python-version }}をセットアップ
      uses: actions/setup-python@v4
      with:
        python-version: ${{ matrix.python-version }}
    
    - name: 依存関係をインストール
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install -r requirements-dev.txt        
    
    - name: テストを実行
      run: |
        pytest --cov=myproject --cov-report=xml        
    
    - name: カバレッジをアップロード
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml

サーバーレス関数のテスト

AWS Lambda関数やサーバーレスアプリケーションをテストする際には、ユニットテストに加えて統合テスト戦略を考慮してください。PythonとTerraformを使用した双子モードAWS Lambdaの構築ガイドでは、サーバーレスPythonアプリケーションのためのテストアプローチがカバーされており、Lambdaハンドラ、SQSコンシューマー、API Gateway統合のテスト方法が説明されています。

テストのベストプラクティスチェックリスト

  • コードの作成前または作成中にテストを書く(TDD)
  • テストを高速に保つ(1秒未満のテスト)
  • テストを独立かつ孤立させること
  • 説明的なテスト名を使用すること
  • AAAパターンに従う(Arrange-Act-Assert)
  • エッジケースとエラーコンディションをテストすること
  • 外部依存をモックすること
  • 80%以上のコードカバレッジを目指すこと
  • CI/CDパイプラインでテストを実行すること
  • テストを定期的にレビューおよびリファクタリングすること
  • 複雑なテストシナリオを文書化すること
  • 共通のセットアップにフィクスチャを使用すること
  • 類似したテストをパラメータ化すること
  • テストをシンプルかつ読みやすくすること

一般的なテストパターン

クラスのテスト

class TestUser:
    @pytest.fixture
    def user(self):
        return User("john_doe", "john@example.com")
    
    def test_username(self, user):
        assert user.username == "john_doe"
    
    def test_email(self, user):
        assert user.email == "john@example.com"
    
    def test_full_name(self, user):
        user.first_name = "John"
        user.last_name = "Doe"
        assert user.full_name() == "John Doe"

一時ファイルを使用したテスト

import pytest
from pathlib import Path

@pytest.fixture
def temp_file(tmp_path):
    """一時ファイルを作成します"""
    file_path = tmp_path / "test_file.txt"
    file_path.write_text("test content")
    return file_path

def test_read_file(temp_file):
    content = temp_file.read_text()
    assert content == "test content"

ファイル生成のテスト

ファイルを生成するコード(PDF、画像、ドキュメントなど)をテストする際には、一時ディレクトリを使用し、ファイルのプロパティを確認します:

@pytest.fixture
def temp_output_dir(tmp_path):
    """一時出力ディレクトリを提供します"""
    output_dir = tmp_path / "output"
    output_dir.mkdir()
    return output_dir

def test_pdf_generation(temp_output_dir):
    pdf_path = temp_output_dir / "output.pdf"
    generate_pdf(pdf_path, content="Test")
    
    assert pdf_path.exists()
    assert pdf_path.stat().st_size > 0

PDF生成のための包括的なテスト例については、PythonでPDFを生成するガイドを参照してください。これは、さまざまなPDFライブラリのためのテスト戦略が含まれています。

モンキーパッチを使用したテスト

def test_environment_variable(monkeypatch):
    monkeypatch.setenv("API_KEY", "test_key_123")
    assert os.getenv("API_KEY") == "test_key_123"

def test_module_attribute(monkeypatch):
    monkeypatch.setattr("module.CONSTANT", 42)
    assert module.CONSTANT == 42

有用なリンクとリソース

関連リソース

Pythonの基本とベストプラクティス

  • Pythonチートシート - Python構文、データ構造、および一般的なパターンの包括的なリファレンス

Pythonの特定のテストケース

サーバーレスおよびクラウドテスト


ユニットテストはPython開発者にとって不可欠なスキルです。unittestまたはpytestを選択しても、テストを一貫して書くこと、保守性を保つこと、開発ワークフローに統合することが重要です。簡単なテストから始め、モックやフィクスチャなどの高度な技術を段階的に導入し、カバレッジツールを使用してテストされていないコードを特定してください。