HypothesisによるPythonのプロパティベーステスト:エッジケース検出の自動化

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

例示ベーステスト(Example-Based Testing)の盲点

多くのエンジニアリングチームは、例示ベースのテスト(Example-Based Testing)に依存しています。関数を記述し、3〜4個の典型的な入力を想定して期待される戻り値をアサーションし、デプロイするという流れです。しかし、このアプローチには明らかな欠点があります。エッジケースが本番環境へすり抜けてしまうことです。適切に処理されていないUnicodeサロゲートペア、空のペイロード、符号付き負のゼロ(-0.0)、オフバイワンエラー(1の差によるバグ)といった潜在的な問題は、手作業で作成したテストデータではめったに表面化しません。

私たちは無意識のうちに、自分の想定を裏付けるような単体テストを設計してしまいます。文字列パーサーをテストする際、ヌルバイト(\x00)や右横書き(RTL)制御文字で満たされた文字列ではなく、"hello" や "user_123" のような無難な値を入力しがちです。Hypothesisは、この経験則や当て推量をプロパティベーステスト(Property-Based Testing)で置き換えます。個別具体的な例をハードコーディングする代わりに、コードが常に満たすべき一般的なルール(不変条件/Invariant)を定義します。すると、エンジンが生成した数百もの順列・組み合わせをコードに送り込み、破綻する境界条件を見つけ出します。

アプローチの比較:例示ベーステスト vs. プロパティベーステスト

配列をチャンク分割するユーティリティを例に、両パラダイムの違いを比較します。

比較軸 例示ベース(pytest) プロパティベース(Hypothesis)
入力ソース 固定のフィクスチャ(例: [1, 2, 3, 4]) 生成戦略(st.lists(st.integers()))
テスト試行数 パラメータ化されたシナリオごとに1回実行 実行ごとに100パターン以上のバリエーションを生成
失敗時の分析 失敗した静的なケースをそのまま表示 入力を最小限の再現例にまで自動縮小(シュリンク)
思考の焦点 「入力Aに対して正確にどの値が返るべきか?」 「すべての有効な入力において成立すべき不変条件は何か?」

Hypothesisのメリットとトレードオフ

静的なテストケースから不変条件のモデリングへと移行することで、明確な利点が得られる一方で、計算コストの増加というトレードオフも生じます。

メリット

  • 自動シュリンク機能(Shrinking): 深くネストされた文字列を含む80要素のリストでテストが失敗した場合でも、Hypothesisは80個の全データを突きつけることはしません。不要な値を削ぎ落とし、失敗の直接的な引き金となった原因を特定します。多くの場合、単一の空文字列 "" や単独の 0 など、最小限のデータにまで圧縮されます。
  • 徹底した境界値の探索: エッジケースを徹底的に洗い出します。空のセット、非正規化浮動小数点数、空白文字の組み合わせ、整数の最大しきい値などが、自動的に関数へ入力されます。
  • 決定論的なリグレッションキャッシュ: Hypothesisはテスト失敗を検出すると、その最小再現データをローカルの .hypothesis ディレクトリに記録します。次回以降のテスト実行では、異なるマシン環境であっても、まずその再現データから優先して検証が行われます。

トレードオフ

  • 実行時間の増加: 標準的な単体テストの実行時間は約0.4ミリ秒ですが、Hypothesisでテストごとに100件のケースを生成して実行すると、30〜120ミリ秒程度まで増加することがあります。
  • 設計思考の転換が必要: 単純な戻り値の比較だけに頼ることはできません。エンコード/デコードの可逆性(ラウンドトリップの対称性)、べき等性、数学的な上下限など、構造的な関係性をアサーションする手法をチーム全体で習得する必要があります。

推奨セットアップ

使用中の仮想環境に両パッケージをインストールします:

pip install pytest hypothesis

ローカル開発時のフィードバックの速さを保ちつつ、プルリクエスト時には網羅的なチェックを実行できるよう、プロジェクトルートの conftest.py に専用プロファイルを登録します:

# conftest.py
import os
from hypothesis import settings

settings.register_profile("ci", max_examples=250, deadline=None)
settings.register_profile("dev", max_examples=40, deadline=300)

# 環境変数経由でプロファイルを切り替え(ローカルのデフォルトはdev)
current_profile = os.getenv("HYPOTHESIS_PROFILE", "dev")
settings.load_profile(current_profile)

実装ガイド

基本価格、割引率、消費税を処理するEコマースの価格計算ロジックを考えてみましょう。こうした処理パイプラインでは、わずかな端数処理の違いや想定外の小数入力によって、請求額の不一致が頻繁に発生します。

1. ビジネスロジックの実装

二進浮動小数点数の丸め誤差を回避するため、Pythonの Decimal モジュールを使用して pricing.py に計算ロジックを実装します:

# pricing.py
from decimal import Decimal, ROUND_HALF_UP

def calculate_final_price(
    base_price: Decimal,
    discount_percent: Decimal,
    tax_percent: Decimal,
) -> Decimal:
    if base_price < Decimal("0.00"):
        raise ValueError("基本価格を負の値にすることはできません")
    if not (Decimal("0.00") <= discount_percent <= Decimal("100.00")):
        raise ValueError("割引率は0から100の間で指定してください")
    if tax_percent < Decimal("0.00"):
        raise ValueError("税率を負の値にすることはできません")

    discount_factor = Decimal("1.00") - (discount_percent / Decimal("100.00"))
    tax_factor = Decimal("1.00") + (tax_percent / Decimal("100.00"))
    
    total = base_price * discount_factor * tax_factor
    return total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

2. 不変プロパティ(Invariant Properties)の記述

「19.99ドル」のような固定値をハードコードするのではなく、システムが常に満たすべき普遍的な性質として要件を定義します:

  1. 割引ゼロ時の下限保証: 割引が0%かつ税率が0以上の場合、最終価格が基本価格を下回ることは絶対にない。
  2. 全額割引の保証: 割引率が100%の場合、税率や元の価格に関係なく、最終価格は必ず正確に Decimal("0.00") となる。
  3. 価格の単調性(Monotonicity): 任意の基本価格と税率において、割引率を上げた場合に最終価格が増加することはない。

test_pricing.py に記述するテストスイートでは、これら3つのルールをそのままコードとして表現します:

# test_pricing.py
from decimal import Decimal
from hypothesis import given, strategies as st
import pytest
from pricing import calculate_final_price

# 業務上現実的な範囲に入力値を制限
valid_money = st.decimals(min_value=Decimal("0.01"), max_value=Decimal("50000.00"), places=2)
valid_percentage = st.decimals(min_value=Decimal("0.00"), max_value=Decimal("100.00"), places=2)

@given(base_price=valid_money, tax=valid_percentage)
def test_zero_discount_preserves_or_increases_price(base_price, tax):
    result = calculate_final_price(base_price, Decimal("0.00"), tax)
    assert result >= base_price

@given(base_price=valid_money, tax=valid_percentage)
def test_full_discount_yields_zero(base_price, tax):
    result = calculate_final_price(base_price, Decimal("100.00"), tax)
    assert result == Decimal("0.00")

@given(
    base_price=valid_money,
    d1=valid_percentage,
    d2=valid_percentage,
    tax=valid_percentage,
)
def test_higher_discount_lowers_or_equals_price(base_price, d1, d2, tax):
    low_discount, high_discount = sorted([d1, d2])
    price_with_low = calculate_final_price(base_price, low_discount, tax)
    price_with_high = calculate_final_price(base_price, high_discount, tax)
    assert price_with_high <= price_with_low

3. テストスイートの実行

詳細ログ(verbose)を有効にしてテストを実行します:

pytest test_pricing.py -v

端数処理の順序を変更してしまったり、入力値の境界チェックを見落としたりした場合、Hypothesisは単にエラーを報告するだけではありません。アサーション違反を引き起こした具体的な境界値を特定し、トレースバックを最小限の再現入力まで自動で圧縮して提示します。

コアなパース処理、財務計算、データシリアライズなどの領域から、プロパティベーステストを導入してみてください。従来の例示ベーステストはハッピーパス(正常系)の動作確認に適していますが、Hypothesisはドメインルールがあらゆる入力パターンにおいて堅牢であることを証明してくれます。

Share: