Hypothesis×pytestで始めるProperty-based Testing実践ガイド
この記事でわかること
- Property-based Testing(PBT)の基本概念と従来のユニットテストとの違い
- Python Hypothesisライブラリを使った4つのテストパターン(Roundtrip・Invariant・Oracle・Fuzzing)の実装方法
-
@st.compositeデコレータによるカスタムストラテジーの設計とshrinking(最小反例探索)の仕組み -
RuleBasedStateMachineを使ったステートフルテストでAPI・データ構造の状態遷移を検証する手法 - CI/CDパイプラインへの統合方法(プロファイル切替・pytest連携・GitHub Actions設定)
対象読者
- 想定読者: pytestでユニットテストを書いた経験がある中級Pythonユーザー
-
必要な前提知識:
- Python 3.10以上の基本文法
- pytestの基本的な使い方(
@pytest.mark、fixture) - テストの基礎概念(アサーション、テストケース設計)
結論・成果
Property-based Testingを導入することで、手書きテストでは見逃しがちなエッジケースを自動的に発見できます。論文「Agentic Property-Based Testing」(arXiv:2510.09907)の報告では、100個のPythonパッケージに対してPBTを適用した結果、56%の有効バグ率でNumPyやAWS Lambda Powertoolsを含む複数のライブラリで実際にパッチがマージされています。また、CI/CDにプロファイル切替を組み込むことで、ローカル開発では高速(10例)、CIでは網羅的(1000例)という使い分けが可能になり、開発サイクルを遅延させずにテスト品質を向上できます。
Property-based Testingの基本概念を理解する
従来のユニットテスト(Example-based Testing)では、開発者が「入力Aに対して出力Bを期待する」と具体的な例を手書きします。Property-based Testingはアプローチが異なり、「すべての有効な入力に対して、出力はこの性質(プロパティ)を満たすべき」 という普遍的な法則を記述します。テストフレームワークが自動的にランダムな入力を生成し、その法則が破られるケースを探索します。
Example-based TestingとPBTの比較
| 観点 | Example-based Testing | Property-based Testing |
|---|---|---|
| テスト入力 | 開発者が手動で選択 | フレームワークが自動生成 |
| カバー範囲 | 選んだ例のみ | 入力空間を広く探索 |
| エッジケース | 思いつく限り手書き | 自動的に境界値・特殊値を試行 |
| 失敗時の出力 | 固定の失敗例 | 最小化された反例(shrinking) |
| 記述内容 | 入力→期待出力のペア | 入力→満たすべき性質 |
| 適した場面 | 要件が固定のビジネスロジック | 数学的性質を持つ関数・変換処理 |
Hypothesisのインストールと最初のテスト
# Python 3.10+ 環境
pip install hypothesis pytest
# またはuvを使う場合
uv add --dev hypothesis pytest
最もシンプルなPBTの例として、整数の加法の交換法則をテストしてみましょう。
# test_basic_pbt.py
from hypothesis import given, strategies as st
@given(st.integers(), st.integers())
def test_addition_is_commutative(x, y):
"""加法の交換法則: x + y == y + x"""
assert x + y == y + x
@given(st.integers(), st.integers(), st.integers())
def test_addition_is_associative(x, y, z):
"""加法の結合法則: (x + y) + z == x + (y + z)"""
assert (x + y) + z == x + (y + z)
@givenデコレータがHypothesisの入口です。st.integers()はストラテジー(データ生成戦略)で、Hypothesisはデフォルトで100個のランダムな整数を生成してテストを実行します。
注意点:
浮動小数点数で同じテストを書くと失敗します。
floatの加法は結合法則を満たさないためです(丸め誤差)。これは「プロパティを正確に記述する」ことの重要性を示す好例です。
4つの実践テストパターンを実装する
Hypothesisを実務で活用するには、テスト対象に適したプロパティパターンを選ぶ必要があります。ここでは実務で頻出する4パターンを具体的なコード例とともに解説します。
パターン1: Roundtrip(往復変換)テスト
エンコード/デコード、シリアライズ/デシリアライズなど、逆変換が存在する処理に適用します。「変換して逆変換したら元に戻る」というプロパティです。
# test_roundtrip.py
import json
from hypothesis import given, strategies as st
# JSON往復テスト: Pythonオブジェクト → JSON文字列 → Pythonオブジェクト
json_values = st.recursive(
st.none() | st.booleans() | st.integers() | st.floats(allow_nan=False) | st.text(),
lambda children: st.lists(children) | st.dictionaries(st.text(), children),
max_leaves=50,
)
@given(json_values)
def test_json_roundtrip(value):
"""JSONシリアライズ→デシリアライズで元の値が復元される"""
serialized = json.dumps(value)
deserialized = json.loads(serialized)
assert deserialized == value
st.recursiveを使うことで、ネストした辞書やリストを含む複雑なJSONデータも自動生成できます。allow_nan=Falseを指定しているのは、NaN != NaNであるため往復テストが成立しないためです。
よくある間違い:
最初はst.floats()をそのまま使ってテストが失敗し、「バグを見つけた」と喜んだが、実際にはNaNやInfinityがJSON仕様で未定義であることが原因だった、というケースがあります。プロパティを書く際は前提条件(precondition)を正確に定義することが重要です。
パターン2: Invariant(不変条件)テスト
操作の前後で常に成り立つべき条件を検証します。データ構造のサイズ、要素の保存、順序関係などが対象です。
# test_invariant.py
from collections import Counter
from hypothesis import given, strategies as st
def my_sort(lst: list[int]) -> list[int]:
"""テスト対象のソート関数"""
return sorted(lst)
@given(st.lists(st.integers()))
def test_sort_preserves_length(lst):
"""ソート後も要素数は変わらない"""
assert len(my_sort(lst)) == len(lst)
@given(st.lists(st.integers()))
def test_sort_preserves_elements(lst):
"""ソート後も要素の構成(頻度含む)は変わらない"""
assert Counter(my_sort(lst)) == Counter(lst)
@given(st.lists(st.integers()))
def test_sort_is_ordered(lst):
"""ソート後は昇順に並んでいる"""
result = my_sort(lst)
for a, b in zip(result, result[1:]):
assert a <= b
制約条件:
不変条件テストだけでは「正しさ」を完全に保証できません。例えばtest_sort_preserves_lengthとtest_sort_is_orderedだけでは、「全要素をリスト最大値で埋める」という誤った実装もパスしてしまいます。Counterによる要素保存チェックを加えて初めてソートの正しさを保証できます。
パターン3: Oracle(参照実装)テスト
最適化した新実装を、信頼できる既存実装(Oracle) と比較するパターンです。リファクタリングやパフォーマンス改善時に有効です。
# test_oracle.py
import math
from hypothesis import given, strategies as st
def fast_gcd(a: int, b: int) -> int:
"""最適化したGCD実装(テスト対象)"""
while b:
a, b = b, a % b
return abs(a)
@given(
st.integers(min_value=1, max_value=10**9),
st.integers(min_value=1, max_value=10**9),
)
def test_fast_gcd_matches_stdlib(a, b):
"""自作GCDがmath.gcdと同じ結果を返す"""
assert fast_gcd(a, b) == math.gcd(a, b)
MLエンジニアの方には馴染み深いと思いますが、これはモデルのリグレッションテストと同じ発想です。既存モデル(Oracle)の出力と新モデルの出力を比較し、意図しない挙動変化を検出します。
パターン4: Fuzzing(クラッシュ検出)テスト
明確なプロパティが定義しづらい場合、「少なくともクラッシュしない」 ことだけを検証するパターンです。パーサーや入力バリデーションに有効です。
# test_fuzzing.py
from hypothesis import given, strategies as st
def parse_config(raw: str) -> dict:
"""設定文字列をパースする関数(テスト対象)"""
result = {}
for line in raw.strip().split("\n"):
if "=" in line:
key, value = line.split("=", 1)
result[key.strip()] = value.strip()
return result
@given(st.text())
def test_parse_config_never_crashes(raw):
"""どんな文字列入力でもクラッシュしない"""
result = parse_config(raw)
assert isinstance(result, dict)
セキュリティの観点からも重要なパターンです。Kiro社のブログでは、Roundtripテストの過程でHypothesisが"__proto__"という文字列を75回目のイテレーションで生成し、JavaScriptのプロトタイプ汚染脆弱性を発見した事例が報告されています。人間が"__proto__"をテスト入力として思いつくのは困難であり、PBTの真価を示す好例です。
カスタムストラテジーとshrinkingを活用する
実務のドメインオブジェクトはst.integers()やst.text()だけでは表現できません。@st.compositeデコレータを使って、ビジネスロジックに適合したカスタムストラテジーを設計する方法を解説します。
@st.compositeでドメインオブジェクトを生成する
# test_custom_strategy.py
from dataclasses import dataclass
from hypothesis import given, strategies as st
@dataclass(frozen=True)
class User:
name: str
age: int
email: str
@st.composite
def user_strategy(draw):
"""有効なUserオブジェクトを生成するカスタムストラテジー"""
name = draw(st.text(min_size=1, max_size=50, alphabet=st.characters(
whitelist_categories=("L", "N", "Zs"),
)))
age = draw(st.integers(min_value=0, max_value=150))
# ドメイン制約: emailは@を含む
local = draw(st.text(min_size=1, max_size=30, alphabet=st.characters(
whitelist_categories=("L", "N"),
)))
domain = draw(st.text(min_size=1, max_size=20, alphabet=st.characters(
whitelist_categories=("L", "N"),
)))
email = f"{local}@{domain}.com"
return User(name=name, age=age, email=email)
@given(user_strategy())
def test_user_email_contains_at(user):
"""生成されたUserのemailは必ず@を含む"""
assert "@" in user.email
@given(user_strategy())
def test_user_age_is_non_negative(user):
"""Userの年齢は非負"""
assert user.age >= 0
draw関数は他のストラテジーから値を「引き出す」機能です。ML的に言えば、各属性のサンプリング分布を個別に定義し、それらを組み合わせてジョイントサンプルを生成しているイメージです。
shrinking(最小反例探索)の仕組み
Hypothesisの最大の強みの一つがshrinkingです。テストが失敗すると、Hypothesisは失敗を再現する最小の入力を探索します。
shrinkingのルールは「ボトムアップ」です。コンポーネントをより単純な値に置き換えたとき、全体もより単純になるように動作します。具体的には:
- 整数: 0に近づける
- 文字列: 短くする、ASCII文字に置き換える
- リスト: 要素を減らす、各要素を小さくする
- 複合型: 各フィールドを個別にshrinkする
ハマりポイント:
filter()やassume()を多用すると、shrinkingがうまく機能しません。shrink後の値がフィルタ条件を満たさなくなるためです。可能な限り生成段階で制約を組み込む(st.integers(min_value=1)など)ことが推奨されます。
例データベースによる回帰テスト
Hypothesisは失敗した最小反例を.hypothesis/examplesディレクトリにローカル保存します。次回テスト実行時、保存された反例が最初に再テストされるため、一度見つけたバグの回帰テストが自動的に実現されます。
# conftest.py でデータベースの場所を設定
from hypothesis import settings
from hypothesis.database import DirectoryBasedExampleDatabase
settings.register_profile(
"default",
database=DirectoryBasedExampleDatabase(".hypothesis/examples"),
)
トレードオフ:
例データベースをGitにコミットすると回帰テストが共有できる一方、テスト実行時間が増加する可能性があります。チームの方針として.hypothesis/を.gitignoreに入れるか否かを決めておくのが望ましいです。
RuleBasedStateMachineでステートフルテストを実装する
ここまでのテストは「単一の関数呼び出し」に対するプロパティでした。しかし実務では、複数の操作が順番に実行されることで初めて顕在化するバグが存在します。REST APIのCRUD操作、データベーストランザクション、キャッシュの整合性などが典型例です。
HypothesisのRuleBasedStateMachineは、操作の列(シナリオ)を自動生成し、状態遷移の中で不変条件が破られるケースを探索します。
インメモリTodoリストのステートフルテスト
# test_stateful_todo.py
from hypothesis import settings
from hypothesis.stateful import (
Bundle,
RuleBasedStateMachine,
rule,
initialize,
invariant,
precondition,
)
from hypothesis import strategies as st
class TodoItem:
def __init__(self, title: str, done: bool = False):
self.title = title
self.done = done
class TodoApp:
"""テスト対象: インメモリTodoアプリ"""
def __init__(self):
self._items: dict[int, TodoItem] = {}
self._next_id = 1
def add(self, title: str) -> int:
item_id = self._next_id
self._items[item_id] = TodoItem(title=title)
self._next_id += 1
return item_id
def remove(self, item_id: int) -> None:
del self._items[item_id]
def toggle(self, item_id: int) -> None:
self._items[item_id].done = not self._items[item_id].done
def list_all(self) -> list[tuple[int, str, bool]]:
return [(k, v.title, v.done) for k, v in self._items.items()]
class TodoStateMachine(RuleBasedStateMachine):
"""TodoAppの状態遷移をテストするステートマシン"""
item_ids = Bundle("item_ids")
@initialize()
def init_app(self):
self.app = TodoApp()
self.model: dict[int, tuple[str, bool]] = {}
@rule(target=item_ids, title=st.text(min_size=1, max_size=100))
def add_item(self, title):
item_id = self.app.add(title)
self.model[item_id] = (title, False)
return item_id
@precondition(lambda self: len(self.model) > 0)
@rule(item_id=item_ids)
def remove_item(self, item_id):
if item_id in self.model:
self.app.remove(item_id)
del self.model[item_id]
@precondition(lambda self: len(self.model) > 0)
@rule(item_id=item_ids)
def toggle_item(self, item_id):
if item_id in self.model:
self.app.toggle(item_id)
title, done = self.model[item_id]
self.model[item_id] = (title, not done)
@invariant()
def items_match_model(self):
"""不変条件: 実装とモデルが常に一致する"""
actual = {k: (v.title, v.done) for k, v in self.app._items.items()}
assert actual == self.model
@invariant()
def count_matches(self):
"""不変条件: 要素数が一致する"""
assert len(self.app._items) == len(self.model)
TestTodoApp = TodoStateMachine.TestCase
TestTodoApp.settings = settings(max_examples=200, stateful_step_count=10)
なぜこの実装を選んだか:
-
preconditionデコレータにより、無効な操作(空リストからの削除)をフィルタリングしています。assume()より効率的で、Hypothesisが事前に無効なルールを除外できます -
Bundleを使うことで、add_itemが返したIDを後続のremove_item/toggle_itemで再利用できます -
モデルベーステスト:
self.model(Python辞書)をOracleとして、テスト対象のTodoAppと常に一致することを@invariantで検証しています
注意点:
ステートフルテストは通常のPBTより計算コストが高くなります。
stateful_step_count(1シナリオあたりの操作数)を制限し、CI環境ではmax_examples=50程度から始めることを推奨します。
CI/CDパイプラインにPBTを統合する
Property-based Testingの真価は、毎コミットで自動実行されてこそ発揮されます。ローカル開発では高速に、CIでは網羅的にテストを実行するためのプロファイル設計と、GitHub Actionsへの統合方法を解説します。
プロファイル設計
# conftest.py
import os
from hypothesis import HealthCheck, Phase, settings
from hypothesis.database import DirectoryBasedExampleDatabase
# ローカル開発: 高速フィードバック(10例、短いdeadline)
settings.register_profile(
"dev",
max_examples=10,
deadline=500, # 500ms
database=DirectoryBasedExampleDatabase(".hypothesis/examples"),
)
# CI: 網羅的テスト(1000例、deadline無制限)
settings.register_profile(
"ci",
max_examples=1000,
deadline=None,
derandomize=True, # 再現性のため決定的に実行
suppress_health_check=[HealthCheck.too_slow],
database=None, # CIでは例データベース不要
print_blob=True, # 再現用のseedを出力
)
# デフォルト: 標準設定(100例)
settings.register_profile(
"default",
max_examples=100,
database=DirectoryBasedExampleDatabase(".hypothesis/examples"),
)
# 環境変数 or pytestフラグで切り替え
settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "default"))
| プロファイル | max_examples | deadline | 用途 |
|---|---|---|---|
| dev | 10 | 500ms | ローカル開発(即座にフィードバック) |
| default | 100 | 200ms | プルリクエスト作成前の確認 |
| ci | 1000 | None | CI/CDでの網羅的テスト |
GitHub Actionsワークフロー
# .github/workflows/pbt.yml
name: Property-Based Tests
on:
push:
branches: [main]
pull_request:
jobs:
pbt:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
pip install -e ".[dev]"
- name: Run property-based tests
env:
HYPOTHESIS_PROFILE: ci
run: |
pytest tests/ -x --hypothesis-profile=ci -v \
--tb=short --no-header -q
- name: Upload failure seeds
if: failure()
uses: actions/upload-artifact@v4
with:
name: hypothesis-seeds-${{ matrix.python-version }}
path: .hypothesis/
なぜderandomize=TrueをCI用に設定するか:
CIでは再現性が最重要です。derandomize=Trueを設定すると、Hypothesisはテスト関数のソースコードからハッシュを計算し、同じコードに対しては常に同じ入力列を生成します。これにより「CIでだけ落ちる」というflaky testを防止できます。
pytest-xdistとの並列実行
Hypothesisは各ワーカーが独立したランダムシードを持つため、pytest-xdistとの並列実行が安全です。
# 4ワーカーで並列実行
pytest tests/ -n 4 --hypothesis-profile=ci
ハマりポイント:
pytest-xdist使用時に例データベースを共有ディレクトリに置くと、ワーカー間で書き込み競合が発生します。CI環境ではdatabase=Noneにするか、ワーカーごとに別ディレクトリを使用してください。
よくある問題と解決方法
| 問題 | 原因 | 解決方法 |
|---|---|---|
Unreliable test timings 警告 |
テスト関数の実行時間がばらつく |
deadline=Noneを設定、または@settings(deadline=2000)で緩める |
FailedHealthCheck: too_slow |
assume()の条件を満たす入力が少ない |
assume()をfilter()に置き換えるか、ストラテジーの制約を強化 |
| テストが遅い(30秒以上) |
max_examplesが大きすぎる or ストラテジーが複雑 |
プロファイルでmax_examples調整、@st.compositeで効率化 |
| shrinkingが収束しない | カスタムストラテジーのshrinkが困難 |
st.builds()やst.from_type()で標準shrinkを利用 |
| CIとローカルで結果が異なる | ランダムシードの違い |
derandomize=TrueをCI設定に追加 |
filter()で99%が除外される |
生成範囲が広すぎる |
st.integers(min_value=..., max_value=...)で範囲を事前に絞る |
まとめと次のステップ
まとめ:
- Property-based Testingは「具体的な例」ではなく「普遍的な性質」を記述するテスト手法で、エッジケースの自動発見に優れている
- Hypothesisは4つのパターン(Roundtrip・Invariant・Oracle・Fuzzing)で幅広いテスト対象をカバーできる
-
@st.compositeでドメイン固有のデータを生成し、shrinkingにより最小反例を自動特定できる -
RuleBasedStateMachineにより、API・データ構造の状態遷移テストを自動化できる - CIプロファイル切替(dev: 10例 / ci: 1000例)で開発速度とテスト品質を両立できる
次にやるべきこと:
- 既存プロジェクトのシリアライズ/デシリアライズ処理にRoundtripテストを追加する
-
conftest.pyにプロファイル設定を追加し、CIパイプラインで--hypothesis-profile=ciを指定する - 状態を持つモジュール(キャッシュ、セッション管理など)に
RuleBasedStateMachineテストを導入する
関連記事:
既にPBTの基礎を理解している方は、Rust proptest・Metamorphic TestingでPBTを本番適用する上級パターンもご覧ください。Rustでのプロパティテストやメタモルフィックテストなど、より高度な手法を解説しています。
参考
- Hypothesis 公式ドキュメント
- HypothesisWorks/hypothesis - GitHub
- Getting Started With Property-Based Testing in Python With Hypothesis and Pytest - Semaphore
- Agentic Property-Based Testing: Finding Bugs Across the Python Ecosystem - arXiv
- Property-Based Testing Caught a Security Bug I Never Would Have Found - Kiro
- Stateful tests - Hypothesis Documentation
- How to Build Property-Based Testing with Hypothesis - OneUptime
注意: この記事はAI(Claude Code)により自動生成されました。内容の正確性については複数の情報源で検証していますが、実際の利用時は公式ドキュメントもご確認ください。