doctest

単体テストPythonドキュメントテストdocstring標準ライブラリインタラクティブ

doctest

概要

doctestは、Pythonの標準ライブラリに含まれるドキュメントテストモジュールです。インタラクティブなPythonセッションのように見えるテキストを検索し、それらのセッションを実行して、表示されているとおりに正確に動作するかを検証します。軽量なテストフレームワークとして、プロジェクトのドキュメントから迅速で簡単なテスト自動化を提供します。

詳細

主要な特徴

ドキュメント統合テスト

  • docstringやドキュメント内のテストケースを読み取り実行
  • ドキュメントとテストの同期を自動的に保証
  • インタラクティブPythonセッション形式でテストケースを記述

標準ライブラリ組み込み

  • Pythonに標準で含まれており、追加インストール不要
  • >>> プロンプト(Primary)と... プロンプト(Secondary)を使用
  • 期待される出力結果をすぐ下の行に記述

自動テスト発見

  • モジュールdocstring、関数、クラス、メソッドのdocstringを検索
  • インポートされたオブジェクトは検索対象外
  • テストケースの自動実行と結果検証

CI/CDパイプライン統合

  • 2024年現在のモダンな開発ワークフローにdoctestを追加可能
  • ドキュメント品質の継続的な維持が可能

メリット・デメリット

メリット

  1. ドキュメント同期: ドキュメントとコードの整合性を自動保証
  2. 学習コストの低さ: Pythonの標準機能として簡単に利用可能
  3. 軽量性: 追加の依存関係やセットアップが不要
  4. 実用例の提供: 実際に動作するコード例をドキュメントに含められる
  5. 即座のフィードバック: コマンド一つでドキュメント例の正確性を確認

デメリット

  1. 限定的なテストカバレッジ: 主に小さなコードスニペットや単純な関数に適用
  2. 出力比較の制限: 印刷出力のみの比較で、可変出力はテスト失敗の原因となる
  3. 複雑なテストには不向き: 複雑な関数やクラスの全側面をカバーできない
  4. エラーメッセージの制限: 標準的なエラーメッセージが時として不十分

参考ページ

書き方の例

基本的なdoctest例

def add(a, b):
    """
    二つの数値を加算します。
    
    >>> add(2, 3)
    5
    >>> add(-1, 1)
    0
    >>> add(0, 0)
    0
    """
    return a + b

def multiply(a, b):
    """
    二つの数値を乗算します。
    
    >>> multiply(3, 4)
    12
    >>> multiply(-2, 5)
    -10
    >>> multiply(0, 100)
    0
    """
    return a * b

if __name__ == "__main__":
    import doctest
    doctest.testmod()

文字列操作のdoctest

def reverse_string(text):
    """
    文字列を逆順にします。
    
    >>> reverse_string("hello")
    'olleh'
    >>> reverse_string("Python")
    'nohtyP'
    >>> reverse_string("")
    ''
    >>> reverse_string("a")
    'a'
    """
    return text[::-1]

def count_vowels(text):
    """
    文字列内の母音の数を数えます。
    
    >>> count_vowels("hello")
    2
    >>> count_vowels("Python")
    1
    >>> count_vowels("aeiou")
    5
    >>> count_vowels("xyz")
    0
    >>> count_vowels("")
    0
    """
    vowels = "aeiouAEIOU"
    return sum(1 for char in text if char in vowels)

リストとコレクション操作

def find_max(numbers):
    """
    リストから最大値を見つけます。
    
    >>> find_max([1, 2, 3, 4, 5])
    5
    >>> find_max([-1, -2, -3])
    -1
    >>> find_max([42])
    42
    >>> find_max([])
    Traceback (most recent call last):
        ...
    ValueError: リストが空です
    """
    if not numbers:
        raise ValueError("リストが空です")
    return max(numbers)

def remove_duplicates(items):
    """
    リストから重複を除去し、順序を保持します。
    
    >>> remove_duplicates([1, 2, 2, 3, 1, 4])
    [1, 2, 3, 4]
    >>> remove_duplicates(['a', 'b', 'a', 'c'])
    ['a', 'b', 'c']
    >>> remove_duplicates([])
    []
    >>> remove_duplicates([1, 1, 1])
    [1]
    """
    seen = set()
    result = []
    for item in items:
        if item not in seen:
            seen.add(item)
            result.append(item)
    return result

クラスのdoctest

class Calculator:
    """
    簡単な計算機クラス。
    
    >>> calc = Calculator()
    >>> calc.add(5, 3)
    8
    >>> calc.subtract(10, 4)
    6
    >>> calc.multiply(6, 7)
    42
    >>> calc.divide(15, 3)
    5.0
    >>> calc.divide(10, 0)
    Traceback (most recent call last):
        ...
    ZeroDivisionError: ゼロで除算はできません
    """
    
    def add(self, a, b):
        """
        >>> calc = Calculator()
        >>> calc.add(2, 3)
        5
        """
        return a + b
    
    def subtract(self, a, b):
        """
        >>> calc = Calculator()
        >>> calc.subtract(10, 4)
        6
        """
        return a - b
    
    def multiply(self, a, b):
        """
        >>> calc = Calculator()
        >>> calc.multiply(3, 4)
        12
        """
        return a * b
    
    def divide(self, a, b):
        """
        >>> calc = Calculator()
        >>> calc.divide(15, 3)
        5.0
        >>> calc.divide(1, 0)
        Traceback (most recent call last):
            ...
        ZeroDivisionError: ゼロで除算はできません
        """
        if b == 0:
            raise ZeroDivisionError("ゼロで除算はできません")
        return a / b

例外処理のテスト

def validate_age(age):
    """
    年齢の妥当性を検証します。
    
    >>> validate_age(25)
    True
    >>> validate_age(0)
    True
    >>> validate_age(120)
    True
    >>> validate_age(-1)
    Traceback (most recent call last):
        ...
    ValueError: 年齢は負の値にはできません
    >>> validate_age(150)
    Traceback (most recent call last):
        ...
    ValueError: 年齢は150歳を超えることはできません
    >>> validate_age("25")
    Traceback (most recent call last):
        ...
    TypeError: 年齢は整数である必要があります
    """
    if not isinstance(age, int):
        raise TypeError("年齢は整数である必要があります")
    if age < 0:
        raise ValueError("年齢は負の値にはできません")
    if age > 150:
        raise ValueError("年齢は150歳を超えることはできません")
    return True

def parse_email(email):
    """
    メールアドレスを解析します。
    
    >>> parse_email("[email protected]")
    ('user', 'example.com')
    >>> parse_email("[email protected]")
    ('test.email', 'domain.org')
    >>> parse_email("invalid-email")
    Traceback (most recent call last):
        ...
    ValueError: 無効なメールアドレス形式です
    >>> parse_email("")
    Traceback (most recent call last):
        ...
    ValueError: 無効なメールアドレス形式です
    """
    if "@" not in email:
        raise ValueError("無効なメールアドレス形式です")
    
    parts = email.split("@")
    if len(parts) != 2 or not parts[0] or not parts[1]:
        raise ValueError("無効なメールアドレス形式です")
    
    return parts[0], parts[1]

ファイルテストの実行

# math_utils.py
def factorial(n):
    """
    階乗を計算します。
    
    >>> factorial(5)
    120
    >>> factorial(0)
    1
    >>> factorial(1)
    1
    """
    if n < 0:
        raise ValueError("負の数の階乗は定義されていません")
    if n == 0 or n == 1:
        return 1
    return n * factorial(n - 1)

# 実行方法
if __name__ == "__main__":
    import doctest
    # 詳細モードでテスト実行
    doctest.testmod(verbose=True)

外部ファイルでのdoctest

# test_examples.txt ファイル

数学的計算のテスト
==================

加算の例:
>>> 2 + 3
5

>>> 10 + (-5)
5

文字列操作の例:
>>> "Hello" + " " + "World"
'Hello World'

>>> "Python".upper()
'PYTHON'

リスト操作の例:
>>> numbers = [1, 2, 3, 4, 5]
>>> len(numbers)
5

>>> numbers[0]
1

>>> numbers[-1]
5

# 実行コード
if __name__ == "__main__":
    import doctest
    # 外部ファイルのテスト実行
    doctest.testfile("test_examples.txt", verbose=True)

カスタム設定でのdoctest実行

def division_with_precision(a, b, precision=2):
    """
    指定された精度で除算を実行します。
    
    >>> division_with_precision(22, 7)  # doctest: +ELLIPSIS
    3.1...
    >>> division_with_precision(1, 3, 4)
    0.3333
    >>> division_with_precision(10, 3)  # doctest: +SKIP
    3.33
    """
    return round(a / b, precision)

def get_current_time():
    """
    現在時刻を取得します(テストでは無視)。
    
    >>> get_current_time()  # doctest: +SKIP
    '2024-01-01 12:00:00'
    """
    from datetime import datetime
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")

# フラグを使用したテスト実行
if __name__ == "__main__":
    import doctest
    # 楕円表記と正規化された空白を許可
    doctest.testmod(
        optionflags=doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE
    )

pytest統合でのdoctest

# pytest.ini または pyproject.toml での設定
[tool.pytest.ini_options]
addopts = "--doctest-modules"

# または実行時
# pytest --doctest-modules

def fibonacci(n):
    """
    フィボナッチ数列のn番目の値を返します。
    
    >>> fibonacci(0)
    0
    >>> fibonacci(1)
    1
    >>> fibonacci(5)
    5
    >>> fibonacci(10)
    55
    """
    if n <= 1:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

# pytest でのdoctest実行例
def test_fibonacci_doctests():
    """pytest でのdoctest実行"""
    import doctest
    import sys
    current_module = sys.modules[__name__]
    result = doctest.testmod(current_module, verbose=True)
    assert result.failed == 0, f"{result.failed} doctests failed"

実践的なdoctest設計パターン

class BankAccount:
    """
    銀行口座クラス。
    
    基本的な使用例:
    >>> account = BankAccount("Alice", 1000)
    >>> account.get_balance()
    1000
    >>> account.deposit(500)
    >>> account.get_balance()
    1500
    >>> account.withdraw(200)
    >>> account.get_balance()
    1300
    
    エラーケース:
    >>> account.withdraw(2000)
    Traceback (most recent call last):
        ...
    ValueError: 残高が不足しています
    >>> account.withdraw(-100)
    Traceback (most recent call last):
        ...
    ValueError: 金額は正の値である必要があります
    """
    
    def __init__(self, name, initial_balance=0):
        """
        新しい口座を作成します。
        
        >>> account = BankAccount("Bob")
        >>> account.get_balance()
        0
        >>> account.name
        'Bob'
        """
        self.name = name
        self.balance = initial_balance
    
    def deposit(self, amount):
        """
        指定金額を預け入れします。
        
        >>> account = BankAccount("Charlie", 100)
        >>> account.deposit(50)
        >>> account.get_balance()
        150
        """
        if amount <= 0:
            raise ValueError("金額は正の値である必要があります")
        self.balance += amount
    
    def withdraw(self, amount):
        """
        指定金額を引き出します。
        
        >>> account = BankAccount("David", 200)
        >>> account.withdraw(50)
        >>> account.get_balance()
        150
        """
        if amount <= 0:
            raise ValueError("金額は正の値である必要があります")
        if amount > self.balance:
            raise ValueError("残高が不足しています")
        self.balance -= amount
    
    def get_balance(self):
        """
        現在の残高を取得します。
        
        >>> account = BankAccount("Eve", 300)
        >>> account.get_balance()
        300
        """
        return self.balance

# テスト実行とカバレッジ確認
if __name__ == "__main__":
    import doctest
    result = doctest.testmod(verbose=True)
    print(f"\nテスト結果: {result.attempted} 実行, {result.failed} 失敗")