PythonのHypothesisライブラリを使ったプロパティベーステストを発見しましょう。例ベースのテストを超えて、エッジケースを見つけ、より堅牢で信頼性の高いソフトウェアを構築します。
単体テストの向こう側へ:PythonのHypothesisを使ったプロパティベーステストの深堀り
ソフトウェア開発の世界では、テストは品質の基盤です。何十年もの間、支配的なパラダイムは例ベーステストでした。私たちは入念に入力を作成し、期待される出力を定義し、コードが計画通りに動作することを確認するためにアサーションを記述します。unittestやpytestのようなフレームワークで見られるこのアプローチは、強力で不可欠です。しかし、あなたが見ることも考えたこともないバグを発見できる補完的なアプローチがあるとしたらどうでしょうか?
プロパティベーステストの世界へようこそ。これは、特定の例をテストすることから、コードの一般的なプロパティを検証することに焦点を移すパラダイムです。そして、Pythonのエコシステムでは、このアプローチの紛れもないチャンピオンはHypothesisと呼ばれるライブラリです。
この包括的なガイドは、完全な初心者からHypothesisを使ったプロパティベーステストの実践者へとあなたを導きます。中核となる概念を探求し、実践的な例を掘り下げ、この強力なツールを毎日の開発ワークフローに統合して、より堅牢で信頼性が高く、バグに強いソフトウェアを構築する方法を学びます。
プロパティベーステストとは? マインドセットの転換
Hypothesisを理解するには、まずプロパティベーステストの基本的な考え方を把握する必要があります。これを、私たち皆が知っている従来の例ベーステストと比較してみましょう。
例ベーステスト:おなじみの道
カスタムソート関数my_sort()を書いたとします。例ベーステストでは、あなたの思考プロセスは次のようになります。
- 「単純な、並び替えられたリストでテストしてみましょう」 ->
assert my_sort([1, 2, 3]) == [1, 2, 3] - 「逆順のリストはどうでしょうか?」 ->
assert my_sort([3, 2, 1]) == [1, 2, 3] - 「空のリストはどうでしょうか?」 ->
assert my_sort([]) == [] - 「重複のあるリストは?」 ->
assert my_sort([5, 1, 5, 2]) == [1, 2, 5, 5] - 「負の数のリストは?」 ->
assert my_sort([-1, -5, 0]) == [-5, -1, 0]
これは効果的ですが、根本的な限界があります。あなたが考えられるケースしかテストしていません。あなたのテストは、あなたの想像力と同じくらいしか優れていません。非常に大きな数値、浮動小数点数の不正確さ、特定のユニコード文字、または予期しない動作につながるデータの複雑な組み合わせを含むエッジケースを見逃す可能性があります。
プロパティベーステスト:不変式で考える
プロパティベーステストは、その脚本を反転させます。特定の例を提供する代わりに、関数のプロパティ、または不変式を定義します。これは、任意の有効な入力に対して真であるべきルールです。my_sort()関数の場合、これらのプロパティは次のようになる可能性があります。
- 出力はソートされています:任意の数値のリストについて、出力リストのすべての要素は、その後に続く要素以下です。
- 出力には入力と同じ要素が含まれています:ソートされたリストは元のリストの並べ替えにすぎません。要素が追加または失われることはありません。
- 関数はべき等です:すでにソートされたリストをソートしても変更はありません。つまり、
my_sort(my_sort(some_list)) == my_sort(some_list)です。
このアプローチを使用すると、テストデータを記述していません。ルールを記述しています。次に、Hypothesisのようなフレームワークを使用して、あなたのプロパティが間違っていることを証明するために、数百または数千のランダムで多様かつしばしば意地悪な入力を生成します。プロパティを破る入力が見つかった場合、バグが見つかりました。
Hypothesisの紹介:あなたの自動テストデータジェネレーター
Hypothesisは、Python向けの主要なプロパティベーステストライブラリです。あなたが定義したプロパティを取り、それらに挑戦するテストデータを生成する難しい作業を行います。これは単なるランダムデータジェネレーターではなく、バグを効率的に見つけるように設計されたインテリジェントで強力なツールです。
Hypothesisの主な機能
- 自動テストケース生成:必要なデータの*形状*を定義します(例:「整数のリスト」、「文字のみを含む文字列」、「未来の日時」)。Hypothesisは、その形状に準拠したさまざまな例を生成します。
- インテリジェントな縮小:これが魔法の機能です。Hypothesisが失敗したテストケース(例:ソート機能をクラッシュさせる50個の複雑な数のリスト)を見つけた場合、その巨大なリストを報告するだけではありません。インテリジェントかつ自動的に入力を単純化して、失敗の原因となる可能な限り最小限の例を見つけます。50要素のリストの代わりに、失敗が
[inf, nan]だけで発生することを報告する場合があります。これにより、デバッグが非常に高速かつ効率的になります。 - シームレスな統合:Hypothesisは、
pytestやunittestなどの一般的なテストフレームワークと完全に統合されています。既存の例ベースのテストの横にプロパティベースのテストを追加し、ワークフローを変更する必要はありません。 - 豊富な戦略ライブラリ:シンプルな整数や文字列から複雑な、ネストされたデータ構造、タイムゾーン対応の日付と時刻、さらにはNumPy配列まで、あらゆるものを生成するための、膨大な組み込み「戦略」のコレクションが付属しています。
- ステートフルテスト:より複雑なシステムの場合、Hypothesisは一連のアクションをテストして、状態遷移のバグを見つけることができます。これは、例ベーステストでは非常に困難なことです。
はじめに:最初のHypothesisテスト
手を汚しましょう。Hypothesisを理解する最良の方法は、それを実際に見てみることです。
インストール
まず、Hypothesisと選択したテストランナー(pytestを使用します)をインストールする必要があります。これは次のように簡単です。
pip install pytest hypothesis
簡単な例:絶対値関数
数値の絶対値を計算することになっている簡単な関数を考えてみましょう。少しバグのある実装は次のようになります。
# `my_math.py`というファイルに
def custom_abs(x):
"""絶対値関数のカスタム実装。"""
if x < 0:
return -x
return x
次に、テストファイルtest_my_math.pyを記述しましょう。まず、従来のpytestアプローチです。
# test_my_math.py (例ベース)
def test_abs_positive():
assert custom_abs(5) == 5
def test_abs_negative():
assert custom_abs(-5) == 5
def test_abs_zero():
assert custom_abs(0) == 0
これらのテストは合格します。これらの例に基づいて、関数は正しいように見えます。しかし、今度はHypothesisでプロパティベースのテストを記述しましょう。絶対値関数のコアプロパティは何ですか?結果は負になることはありません。
# test_my_math.py (Hypothesisによるプロパティベース)
from hypothesis import given
from hypothesis import strategies as st
from my_math import custom_abs
@given(st.integers())
def test_abs_property_is_non_negative(x):
"""プロパティ:任意の整数の絶対値は常に>= 0です。"""
assert custom_abs(x) >= 0
これを分解してみましょう。
from hypothesis import given, strategies as st:必要なコンポーネントをインポートします。givenは、通常のテスト関数をプロパティベースのテストに変えるデコレータです。strategiesは、データジェネレータを見つけるモジュールです。@given(st.integers()):これはテストのコアです。@givenデコレータは、このテスト関数を複数回実行するようにHypothesisに指示します。各実行について、提供された戦略st.integers()を使用して値を生成し、それを引数xとしてテスト関数に渡します。assert custom_abs(x) >= 0:これが私たちのプロパティです。Hypothesisが思いついた整数xについて、関数の結果が0以上でなければならないことをアサートします。
これをpytestで実行すると、多くの値で合格する可能性があります。Hypothesisは0、-1、1、大きな正の数、大きな負の数などを試します。私たちの簡単な関数はこれらすべてを正しく処理します。次に、異なる戦略を試して、弱点が見つかるかどうかを確認しましょう。
# 浮動小数点数でテストしてみましょう
@given(st.floats())
def test_abs_floats_property(x):
assert custom_abs(x) >= 0
これを実行すると、Hypothesisはすぐに失敗するケースを見つけます!
Falsifying example: test_abs_floats_property(x=nan) ... assert custom_abs(nan) >= 0 AssertionError: assert nan >= 0
Hypothesisは、関数にfloat('nan')(Not a Number)が与えられた場合、nanを返すことを発見しました。アサーションnan >= 0はfalseです。手動でテストすることを考えなかったであろう微妙なバグを見つけました。ValueErrorを発生させるか、特定の値を返すことで、このケースを処理するように関数を修正できます。
さらに良いことに、バグが非常に特定のfloatにあった場合はどうでしょうか?Hypothesisの縮小機能は、大規模で複雑な失敗数を取り、バグをトリガーする可能な限り単純なバージョンに削減します。
戦略の力:テストデータの作成
戦略はHypothesisの心臓部です。それらはデータを生成するためのレシピです。ライブラリには、膨大な数の組み込み戦略が含まれており、それらを組み合わせてカスタマイズすることで、想像できるほぼすべてのデータ構造を生成できます。
一般的な組み込み戦略
- 数値:
st.integers(min_value=0, max_value=1000):オプションで特定の範囲内で整数を生成します。st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False):特殊な値を細かく制御してfloatを生成します。st.fractions()、st.decimals()
- テキスト:
st.text(min_size=1, max_size=50):特定の長さのユニコード文字列を生成します。st.text(alphabet='abcdef0123456789'):特定の文字セット(例:16進コード)から文字列を生成します。st.characters():個々の文字を生成します。
- コレクション:
st.lists(st.integers(), min_size=1):各要素が整数であるリストを生成します。別の戦略を引数として渡す方法に注意してください!これは構成と呼ばれます。st.tuples(st.text(), st.booleans()):固定された構造を持つタプルを生成します。st.sets(st.integers())st.dictionaries(keys=st.text(), values=st.integers()):指定されたキーと値の型を持つ辞書を生成します。
- 時間的:
st.dates()、st.times()、st.datetimes()、st.timedeltas()。これらはタイムゾーン対応にすることができます。
- その他:
st.booleans():TrueまたはFalseを生成します。st.just('constant_value'):常に同じ単一の値を生成します。複雑な戦略を構成するのに役立ちます。st.one_of(st.integers(), st.text()):提供された戦略の1つから値を生成します。st.none():Noneのみを生成します。
戦略の組み合わせと変換
Hypothesisの真の力は、単純なものから複雑な戦略を構築できることにあります。
.map()の使用
.map()メソッドを使用すると、1つの戦略から値を取り、それを別のものに変換できます。これは、カスタムクラスのオブジェクトを作成するのに最適です。
# 簡単なデータクラス
from dataclasses import dataclass
@dataclass
class User:
user_id: int
username: str
# Userオブジェクトを生成するための戦略
user_strategy = st.builds(
User,
user_id=st.integers(min_value=1),
username=st.text(min_size=3, alphabet='abcdefghijklmnopqrstuvwxyz')
)
@given(user=user_strategy)
def test_user_creation(user):
assert isinstance(user, User)
assert user.user_id > 0
assert user.username.isalpha()
.filter()とassume()の使用
生成された特定の値を拒否する必要がある場合があります。たとえば、合計が0ではない整数のリストが必要な場合があります。.filter()を使用できます。
st.lists(st.integers()).filter(lambda x: sum(x) != 0)
ただし、.filter()を使用すると非効率になる可能性があります。条件が頻繁にfalseの場合、Hypothesisは有効な例を生成するのに長い時間を費やす可能性があります。より良いアプローチは、多くの場合、テスト関数内でassume()を使用することです。
from hypothesis import assume
@given(st.lists(st.integers()))
def test_something_with_non_zero_sum_list(numbers):
assume(sum(numbers) != 0)
# ... ここにテストロジックを記述します ...
assume()はHypothesisに伝えます。「この条件が満たされない場合は、この例を破棄して、新しいものを試してください。」これは、テストデータを制約するためのより直接的で、多くの場合、より効率的な方法です。
st.composite()の使用
ある生成された値が別の値に依存する、本当に複雑なデータ生成には、st.composite()が必要です。これは、他の戦略から値を段階的に取得するために使用できる、特別なdraw関数を引数として取る関数を記述できます。
古典的な例は、リストとそのリストへの有効なインデックスを生成することです。
@st.composite
def list_and_index(draw):
# まず、空でないリストを作成します
my_list = draw(st.lists(st.integers(), min_size=1))
# 次に、そのリストに対して有効であることが保証されているインデックスを作成します
index = draw(st.integers(min_value=0, max_value=len(my_list) - 1))
return (my_list, index)
@given(data=list_and_index())
def test_list_access(data):
my_list, index = data
# このアクセスは、戦略の構築方法が原因で安全であることが保証されています
element = my_list[index]
assert element is not None # 簡単なアサーション
Hypothesisの実践:現実世界のシナリオ
これらの概念を、ソフトウェア開発者が毎日直面する、より現実的な問題に適用しましょう。
シナリオ1:データシリアル化関数のテスト
ユーザープロファイル(辞書)をURLセーフな文字列にシリアル化し、それを逆シリアル化する関数があるとします。重要なプロパティは、そのプロセスが完全に可逆的であることです。
import json
import base64
def serialize_profile(data: dict) -> str:
"""辞書をURLセーフなbase64文字列にシリアル化します。"""
json_string = json.dumps(data)
return base64.urlsafe_b64encode(json_string.encode('utf-8')).decode('utf-8')
def deserialize_profile(encoded_str: str) -> dict:
"""文字列を辞書に戻します。"""
json_string = base64.urlsafe_b64decode(encoded_str.encode('utf-8')).decode('utf-8')
return json.loads(json_string)
# テスト用
# JSON互換の辞書を生成する戦略が必要です
json_dictionaries = st.dictionaries(
keys=st.text(),
values=st.recursive(st.none() | st.booleans() | st.floats(allow_nan=False) | st.text(),
lambda children: st.lists(children) | st.dictionaries(st.text(), children),
max_leaves=10)
)
@given(profile=json_dictionaries)
def test_serialization_roundtrip(profile):
"""プロパティ:エンコードされたプロファイルを逆シリアル化すると、元のプロファイルが返されます。"""
encoded = serialize_profile(profile)
decoded = deserialize_profile(encoded)
assert profile == decoded
この単一のテストは、空の辞書、ネストされたリストを持つ辞書、ユニコード文字を持つ辞書、奇妙なキーを持つ辞書など、さまざまなデータを使用して関数を叩きます。いくつかの手動例を記述するよりもはるかに徹底的です。
シナリオ2:ソートアルゴリズムのテスト
ソートの例を再検討しましょう。以下は、先ほど定義したプロパティをテストする方法です。
from collections import Counter
def my_buggy_sort(numbers):
# 少しのバグを導入しましょう:重複を削除します
return sorted(list(set(numbers)))
@given(st.lists(st.integers()))
def test_sorting_properties(numbers):
sorted_list = my_buggy_sort(numbers)
# プロパティ1:出力はソートされています
for i in range(len(sorted_list) - 1):
assert sorted_list[i] <= sorted_list[i+1]
# プロパティ2:要素は同じです(これがバグを見つけます)
assert Counter(numbers) == Counter(sorted_list)
# プロパティ3:関数はべき等です
assert my_buggy_sort(sorted_list) == sorted_list
このテストを実行すると、Hypothesisはすぐにnumbers=[0, 0]などのプロパティ2の失敗例を見つけます。私たちの関数は[0]を返し、Counter([0, 0])はCounter([0])と等しくありません。縮小機能は、失敗する例が可能な限り単純であることを保証し、バグの原因をすぐに明らかにします。
シナリオ3:ステートフルテスト
時間の経過とともに内部状態が変化するオブジェクト(データベース接続、ショッピングカート、またはキャッシュなど)の場合、バグを見つけることは非常に困難です。障害をトリガーするには、特定の一連の操作が必要になる場合があります。Hypothesisは、まさにこの目的のためにRuleBasedStateMachineを提供します。
インメモリキーバリューストアのシンプルなAPIを想像してください。
class SimpleKeyValueStore:
def __init__(self):
self._data = {}
def set(self, key, value):
self._data[key] = value
def get(self, key):
return self._data.get(key)
def delete(self, key):
if key in self._data:
del self._data[key]
def size(self):
return len(self._data)
その動作をモデル化し、状態マシンでテストできます。
from hypothesis.stateful import RuleBasedStateMachine, rule, Bundle
class KeyValueStoreMachine(RuleBasedStateMachine):
def __init__(self):
super().__init__()
self.model = {}
self.sut = SimpleKeyValueStore()
# Bundle()は、ルール間でデータを渡すために使用されます
keys = Bundle('keys')
@rule(target=keys, key=st.text(), value=st.integers())
def set_key(self, key, value):
self.model[key] = value
self.sut.set(key, value)
return key
@rule(key=keys)
def delete_key(self, key):
del self.model[key]
self.sut.delete(key)
@rule(key=st.text())
def get_key(self, key):
model_val = self.model.get(key)
sut_val = self.sut.get(key)
assert model_val == sut_val
@rule()
def check_size(self):
assert len(self.model) == self.sut.size()
# テストを実行するには、マシンのサブクラスを作成し、unittest.TestCase
# pytestでは、テストをマシンクラスに割り当てるだけです
TestKeyValueStore = KeyValueStoreMachine.TestCase
Hypothesisは、set_key、delete_key、get_key、およびcheck_size操作のランダムなシーケンスを実行し、アサーションの1つが失敗するシーケンスを見つけようとします。削除されたキーの取得が正しく動作するかどうか、複数の設定と削除後のサイズの一貫性、および手動でテストすることを考えない可能性のある他の多くのシナリオを確認します。
ベストプラクティスと高度なヒント
- 例のデータベース:Hypothesisはスマートです。バグを見つけると、失敗した例をローカルディレクトリ(
.hypothesis/)に保存します。テストを次回実行すると、最初にその失敗した例を再生し、バグがまだ存在することに関する即時フィードバックを提供します。修正すると、例は再生されなくなります。 @settingsを使用したテスト実行の制御:@settingsデコレータを使用して、テスト実行の多くの側面を制御できます。例の数を増やし、単一の例の実行時間を制限する(無限ループをキャッチするため)ことができます。また、特定のヘルスチェックをオフにすることもできます。@settings(max_examples=500, deadline=1000) # 500個の例を実行、1秒の締め切り @given(...) ...
- 障害の再現:すべてのHypothesis実行はシード値を出力します(例:
@reproduce_failure('version', 'seed'))。CIサーバーがローカルで再現できないバグを見つけた場合は、このデコレータと提供されたシードを使用して、Hypothesisにまったく同じ例のシーケンスを実行させることができます。 - CI / CDとの統合:Hypothesisは、あらゆる継続的インテグレーションパイプラインに最適です。本番環境に到達する前に、わかりにくいバグを見つけることができるため、非常に貴重なセーフティネットになります。
マインドセットの転換:プロパティで考える
Hypothesisを採用することは、単に新しいライブラリを学ぶこと以上のことです。コードの正しさについて新しい考え方を採用することです。代わりに、「どのような入力をテストする必要があるか?」と尋ねる代わりに、「このコードに関する普遍的な真実は何ですか?」と尋ね始めます。
プロパティを特定しようとするときに役立つ質問を次に示します。
- 逆の操作はありますか?(例:シリアル化/逆シリアル化、暗号化/復号化、圧縮/解凍)。プロパティは、操作とその逆を実行すると元の入力が得られることです。
- 操作はべき等ですか?(例:
abs(abs(x)) == abs(x))。関数を複数回適用すると、1回適用した場合と同じ結果が得られます。 - 同じ結果を計算する別の、より簡単な方法はありますか?複雑で最適化された関数が、単純で明らかに正しいバージョンと同じ出力を生成すること(例:Pythonの組み込み
sorted()に対して、あなたの派手なソートをテストする)をテストできます。 - 出力については常に何が真実であるべきですか?(例:
find_prime_factors関数の出力には素数のみが含まれ、それらの積は入力と等しくなる必要があります)。 - 状態はどのように変化しますか?(ステートフルテストの場合)有効な操作の後、どのような不変式を維持する必要がありますか?(例:ショッピングカート内のアイテムの数は負になることはありません)。
結論:新たなレベルの自信
Hypothesisを使用したプロパティベーステストは、例ベーステストに取って代わるものではありません。重要なビジネスロジックと十分に理解された要件(例:「国Xのユーザーは価格Yを見る必要がある」)については、手書きのテストが必要です。
Hypothesisが提供するのは、コードの動作を調べ、予期しないエッジケースから保護するための、強力で自動化された方法です。それは疲れを知らないパートナーとして機能し、人間の誰かが現実的に書くことができるよりも多様で意地悪な数千のテストを生成します。コードの基本的なプロパティを定義することにより、Hypothesisがテストできる堅牢な仕様を作成し、ソフトウェアに新たなレベルの自信を与えることができます。
次回関数を記述するときは、例を超えて考える時間を取ってください。自問自答してください。「ルールは何ですか?何が常に真実である必要がありますか?」次に、Hypothesisにそれらを壊すための難しい作業をさせてください。あなたはそれが何を見つけるかに驚かれることでしょう、そしてあなたのコードはそれのために良くなるでしょう。