従来のユニットテストを超えて
私たちは皆、assert add(1, 2) == 3のようなテストを書いたことがあります。これはシンプルで分かりやすいですが、多くの場合、危険なほど不完全です。
「例示ベースのテスト(Example-based testing)」は、何が壊れるかを予測するあなたの能力に完全に依存しています。500文字の文字列、64ビット整数のオーバーフロー、あるいはヌルバイトのテストを忘れてしまえば、それらのバグはいずれ本番環境のログに現れることになります。2つの32ビット整数を受け取る関数でさえ、1,800京通り以上の入力の組み合わせが存在します。手動のアサーションだけでそれらすべてをカバーすることは不可能です。
プロパティベーステスト(PBT)は、そのアプローチを逆転させます。コードに特定の値を与える代わりに、常に真であるべき「プロパティ(性質)」を定義します。Hypothesisは、PythonにおけるPBTの業界標準です。これは、カオスなQAエンジニアのように振る舞い、何かが壊れるまで、数千もの多様でランダム化された入力を関数に投げ込みます。
環境のセットアップ
Hypothesisはpytestと直接統合されているため、インストールは簡単です。ターミナルで以下のコマンドを実行してください。
pip install hypothesis
Hypothesisは複雑な設定ファイルやボイラープレートを必要としません。Pythonのデコレータを使用して既存のテストスイートにデータを注入するため、段階的な導入が容易です。
プロパティとストラテジーの定義
Hypothesis is、ストラテジー(Strategies)と@givenデコレータという2つの主要な構成要素に依存しています。ストラテジーはデータの形状を定義し、@givenは、そのデータを使用してテストを繰り返し実行するようHypothesisに指示します。
リストを反転させる関数を考えてみましょう。このロジックの核心的なプロパティは、「リストを2回反転させると元のリストに戻る」ということです。これをコードで表現すると以下のようになります。
from hypothesis import given
import hypothesis.strategies as st
def reverse_list(items):
return items[::-1]
@given(st.lists(st.integers()))
def test_reverse_twice(items):
assert reverse_list(reverse_list(items)) == items
このスニペットでは、st.lists(st.integers())が整数のリストを生成するようエンジンに伝えています。空のリスト、100万個の要素を持つリスト、sys.maxsizeを含むリストなどが試行されます。デフォルトでは、Hypothesisはこのテストを異なるバリエーションで100回実行し、人間が1時間かけて行うよりも多くのケースを数ミリ秒でカバーします。
よく使われるストラテジー
このライブラリには、現実世界のデータをシミュレートするための多様な組み込みストラテジーが含まれています。
st.text(): 絵文字、制御文字、右から左に書く言語(RTL)を含むUnicode文字列を生成します。st.floats():NaN、inf(無限大)、および数学的ロジックを破壊しがちな非正規化数(subnormal numbers)を含む数値を生成します。st.dictionaries(): 複雑なネストされたマッピングを構築します。st.emails(): バリデーションテスト用に、有効な形式のメールアドレスを生成します。
これらのストラテジーに制約を加えることもできます。例えば、st.integers(min_value=0, max_value=100)は、年齢フィールドやパーセント計算のテストに最適です。
シュリンキング(縮小)の威力
5MBのJSON文字列や、1,000個のランダムな浮動小数点数のリストによって引き起こされた失敗をデバッグするのは悪夢です。Hypothesisは「シュリンキング(Shrinking)」と呼ばれるプロセスを通じてこれを解決します。
Hypothesisはアサーションエラーを引き起こす入力を見つけても、そこで止まりません。失敗の原因となる「最小限の入力」になるまで、入力を簡略化しようと試みます。もしコードが「5,000」という数字を含むリストでクラッシュした場合、Hypothesisはより小さな値を試します。そして、実際のバグが「0より大きい任意の整数」によって引き起こされることを突き止めるかもしれません。
# 失敗時の出力例
Falsifying example: test_function(
items=[0], # Hypothesisが複雑なリストをこの単一のゼロにまで簡略化した
)
この機能により、曖昧なクラッシュレポートが正確な診断へと変わります。エッジケースを効果的に特定してくれるため、手動で原因を切り分ける時間を大幅に節約できます。
CI/CDパイプラインでの検証
GitHub ActionsのようなCI/CD環境でランダム化されたテストを実行するのは、リスクがあると感じるかもしれません。一度失敗してその後消えてしまうような「不安定な(flaky)」テストを心配するでしょう。Hypothesisは、.hypothesisディレクトリに失敗した例のローカルデータベースを保持することで、これを防ぎます。
テストが失敗すると、Hypothesisはその特定の入力を保存します。次回テストスイートを実行するとき、まずその保存された入力をチェックします。CIでこれを最大限に活用するには、.hypothesisフォルダをキャッシュする必要があります。これにより、一度見つかったバグは、コードが修正されるまで確実に見つかり続けるようになります。
探索の微調整
重要な金融ロジックやセキュリティロジックの場合、100個の例では不十分かもしれません。settingsモジュールを使用して、探索の強度を簡単にスケールアップできます。
from hypothesis import settings
@settings(max_examples=1000)
@given(st.integers())
def test_high_stakes_logic(n):
...
ラウンドトリップパターン
PBTの強力な活用方法の一つに「ラウンドトリップ(Round-Trip)」テストがあります。これはシリアライズロジックに最適です。辞書をJSONに変換し、それを再び辞書に戻した場合、結果は元のものと一致しなければなりません。私はこれを、カスタムエンコーダが精度を失ったり特殊文字を壊したりしていないかを確認するために常に使用しています。
@given(st.dictionaries(st.text(), st.integers()))
def test_json_roundtrip(data):
import json
assert json.loads(json.dumps(data)) == data
従来のテストを使い続けるべき場面
Hypothesisは強力ですが、あらゆる場面で適切なツールというわけではありません。低速なネットワークコール、重いデータベースへの書き込み、外部APIを含むテストでの使用は避けてください。エンジンが関数を数百回実行するため、APIのレイテンシが500msあると、テストスイートの完了に数分かかってしまいます。PBTは、純粋なロジック、データ変換、バリデーションルールに限定して使用しましょう。統合テスト(Integration testing)には、標準的なpytestのモックを使用するのが依然として最良の選択です。
「この特定の入力は機能するか?」から「システムが常に従わなければならないルールは何か?」へとマインドセットを切り替えてください。そうすることで、Hypothesisが想像もしていなかったバグを暴き出してくれることに気づくでしょう。テストスイートは、単なるセーフティネットから、能動的なバグハンティングマシンへと進化します。

