例示ベーステスト(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ドル」のような固定値をハードコードするのではなく、システムが常に満たすべき普遍的な性質として要件を定義します:
- 割引ゼロ時の下限保証: 割引が0%かつ税率が0以上の場合、最終価格が基本価格を下回ることは絶対にない。
- 全額割引の保証: 割引率が100%の場合、税率や元の価格に関係なく、最終価格は必ず正確に
Decimal("0.00")となる。 - 価格の単調性(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はドメインルールがあらゆる入力パターンにおいて堅牢であることを証明してくれます。

