Pythonのプロパティベーステスト入門—Hypothesisで境界値を自動生成

Pythonのプロパティベーステスト入門—Hypothesisで境界値を自動生成 | mohablog

記事タイトルから URL スラッグを作る関数に手書きのテストを3本通して、緑。そのあと 31文字のタイトルで末尾ハイフンが残るバグが出ました。Hypothesis に入力を作らせたら、同じバグは10例目で出ます。

検証環境は hypothesis 6.163.0 / Python 3.14.6 / pytest 9.1.1。

目次

手書きのテストが素通りする場所

3本書いて緑になったテスト

対象はこの関数。タイトルを小文字化して、英数字以外をハイフンに潰し、30文字で切ります。

import re


def slugify(title: str, max_len: int = 30) -> str:
    s = title.lower().strip()
    s = re.sub(r"[^a-z0-9]+", "-", s)
    s = s.strip("-")
    return s[:max_len]

テストは英字だけ、記号混じり、日本語混じりの3本。

from slugify import slugify


def test_ascii_title():
    assert slugify("Hello World") == "hello-world"


def test_symbols():
    assert slugify("Python 3.14: free-threading") == "python-3-14-free-threading"


def test_japanese_mixed():
    assert slugify("Go言語のcontext入門") == "go-context"
$ pytest test_manual.py -q
...                                                                      [100%]
3 passed in 0.05s

踏んでいない条件は「長さ」だった

この3本が一度も触れていない経路があります。max_len=30 での切り詰め。strip("-") がスライスよりに走るので、切った位置の直前がハイフンだと末尾に残ります。

>>> slugify("Python Protocol for type-safe DI design")
'python-protocol-for-type-safe-'

末尾ハイフン付きのスラッグ。31文字以上のタイトルでしか起きません。テストケースを自分で並べる限り、31という数字を先に思いつかないとこのケースは書けない。

@given で入力を渡す側を手放す

テストは4行で書ける

公式ドキュメントの Quickstart は “Write your first test” というセクションから始まります。書くのは具体的な入力ではなく、入力によらず成り立つ条件です。

from hypothesis import given, strategies as st
from slugify import slugify


@given(st.text(alphabet="ab-", min_size=25))
def test_slug_has_no_trailing_hyphen(title):
    assert not slugify(title).endswith("-")
$ pytest test_shrink.py -q
E       AssertionError: assert not True
E       Failing test case: test_slug_has_no_trailing_hyphen(
E           title='aaaaaaaaaaaaaaaaaaaaaaaaaaaaa-a',
E       )
1 failed in 0.23s

日本語の解説記事では Falsifying example: という見出しで説明されていることが多いのですが、6.163.0 の出力は Failing test case: です。ログを grep するときはこちらで。

報告される入力は最小まで縮む

'aaaaaaaaaaaaaaaaaaaaaaaaaaaaa-a' は31文字。バグが起きる最短の長さです。ただし Hypothesis が最初に引き当てた反例は、これではありません。

$ pytest test_shrink.py -q --hypothesis-verbosity=verbose
    title='-b-a-bbbbb-----b--abbbbbbbaaaa-bbbb--b-b---baaa-baabbaa-aaaaababb
-ab-b--bbabb-b-aaaa----ab---aaaaabbab-a-aabb-aaa--baabab-baa'
    title='ab-bb-aaa---aaa-a--bba-aa-baa'
    title='baa-a-b--babaaa-ba-aaaabbaaaaa-a-babaab--bb'
    ...
    title='aaaaaaaaaaaaaaaaaaaaaaaaaaaaa-a'

127文字から 31文字へ。この縮小(shrinking)の内訳は統計に出ます。

$ pytest test_shrink.py -q --hypothesis-show-statistics
  - during generate phase (0.01 seconds):
    - 8 passing, 2 failing, and 0 invalid test cases
  - during shrink phase (0.13 seconds):
    - 47 passing, 61 failing, and 0 invalid test cases
    - Tried 108 shrinks of which 60 were successful

10例目で失敗を引き当て、そこから 108回の縮小を試して 60回が成功。0.13秒。デバッガに載せるのは縮んだあとの31文字だけで済みます。

assume() で前提の外を捨てる

別の性質も書けます。「スラッグは空にならない」。

@given(st.text())
def test_slug_is_not_empty(title):
    assert slugify(title) != ""
E       Failing test case: test_slug_is_not_empty(
E           title='',
E       )

空文字。呼び出し側で弾く前提なら、assume() で対象から外します。

from hypothesis import assume, given, strategies as st


@given(st.text())
def test_slug_is_not_empty(title):
    assume(title.strip())
    assert slugify(title) != ""
E       Failing test case: test_slug_is_not_empty(
E           title='\x1b',
E       )

今度はエスケープ文字。空白でも空文字でもない入力が空スラッグになります。日本語だけのタイトルでも同じ結果。性質を1行書いただけで、仕様の穴が2つ出てきました。

ストラテジの当て方で検出率が変わる

st.text() は31文字以上を1000件中5件しか作らない

さきほど min_size=25 を指定した理由があります。デフォルトの st.text() が作る文字列の長さを1000件ぶん数えました。

from collections import Counter
from hypothesis import given, settings, strategies as st

lens = Counter()


@settings(max_examples=1000, database=None)
@given(st.text())
def collect(s):
    lens[len(s)] += 1


collect()
total = sum(lens.values())
over30 = sum(v for k, v in lens.items() if k > 30)
print("生成した文字列:", total)
print("31文字以上:", over30, f"({over30 / total * 100:.1f}%)")
print("最頻の長さ:", lens.most_common(5))
生成した文字列: 1000
31文字以上: 5 (0.5%)
最頻の長さ: [(2, 153), (1, 140), (3, 115), (4, 94), (5, 85)]

最頻は2文字。31文字以上は 0.5% しかありません。デフォルトの100試行なら期待値は0.5件。max_len=30 の境界バグは、st.text() をそのまま渡しても踏めない計算になります。

実際 @given(st.text()) のままデータベースを消して5回まわしましたが、5回とも緑でした。

min_size と alphabet で境界に寄せる

同じ性質、同じ100試行。ストラテジだけを差し替えた結果です。

  • st.text() は5回まわして検出なし
  • st.text(min_size=25) で検出。ただし反例に制御文字が混ざって読みにくい
  • st.text(alphabet="ab-", min_size=25) なら10例目で検出、0.23秒

alphabet を絞ると shrink 後の反例が読みやすくなります。'aaa…a-a' と出れば原因は目で追える。Unicode 全域から拾った制御文字が並ぶ反例より、原因の特定が速い。

max_len 側も可変にするなら、必要なタイトル長が max_len に依存します。Quickstart の “Dependent generation” が扱う形。@st.composite で組みます。

@st.composite
def title_and_limit(draw):
    limit = draw(st.integers(min_value=8, max_value=60))
    title = draw(st.text(alphabet=st.sampled_from("ab-"), min_size=limit + 1))
    return title, limit


@given(title_and_limit())
def test_no_trailing_hyphen_at_any_limit(pair):
    title, limit = pair
    assert not slugify(title, max_len=limit).endswith("-")
E       Failing test case: test_no_trailing_hyphen_at_any_limit(
E           pair=('aaaaaaa-a', 8),
E       )
1 failed in 0.15s

上限8での最小反例。9文字の入力で、8文字目がハイフン。draw() の結果を次の draw() に渡せるので、こうした連動する入力が書けます。

絞り込みは assume ではなくストラテジ側で

ほしい入力の形が決まっているとき、assume() で弾く書き方をすると止まります。

@given(st.text())
def test_over_filtered(title):
    assume(title.startswith("py-"))
    assert len(title) >= 3
E   hypothesis.errors.FailedHealthCheck: It looks like this test is filtering
    out a lot of inputs. 0 inputs were generated successfully, while 50 inputs
    were filtered out.

st.text() がランダムに py- で始まる文字列を作る確率はほぼゼロ。50件連続で捨てられた時点でヘルスチェックが止めます。直し方は suppress_health_check での抑制ではなく、生成する側で形を作ること。

@given(st.text().map(lambda s: "py-" + s))
def test_prefixed(title):
    assert len(title) >= 3
1 passed in 0.62s

assume() はたまに紛れ込む不正な入力を捨てる用。ほしい入力を絞り込むのはストラテジの仕事です。

書ける性質の型

何を assert するか。手が止まったときは、この形のどれかに当てはめると書けます。

書く条件slugify での例
不変条件出力が常に満たす制約末尾がハイフンでない / 空にならない
冪等性f(f(x)) == f(x)スラッグを再度 slugify しても変わらない
往復decode(encode(x)) == xURL エンコードや JSON シリアライズで使う
参照実装との一致旧実装と新実装の出力が等しい正規表現版と手書きループ版を突き合わせる

リファクタリング中は最後のパターンが効きます。旧実装を残したまま両方に同じ入力を通し、差分だけを探す。

hypothesis write でテストの下書きを出す

Integrations Reference の “Ghostwriter” セクションに、関数を渡すとテストコードを出力する CLI があります。

$ hypothesis write slugify

The Hypothesis command-line interface requires the `click` package,
which you do not have installed.  Run:

    python -m pip install --upgrade 'hypothesis[cli]'

pip install hypothesis だけでは CLI が動きません。[cli] extra を入れてから叩きます。

magic モードは型ヒントからストラテジを引く

$ pip install 'hypothesis[cli]'
$ hypothesis write slugify
# This test code was written by the `hypothesis.extra.ghostwriter` module
# and is provided under the Creative Commons Zero public domain dedication.

import slugify
from hypothesis import given, strategies as st


@given(title=st.text(), max_len=st.integers())
def test_fuzz_slugify(title: str, max_len: int) -> None:
    slugify.slugify(title=title, max_len=max_len)

title: str から st.text()max_len: int から st.integers()。型ヒントを書いてある関数ほど精度の高い下書きが出ます。型チェッカーを通しているコードなら、そのまま流用できる形で出力されます。

--idempotent なら冪等性のテスト。

$ hypothesis write --idempotent slugify.slugify

@given(title=st.text(), max_len=st.integers())
def test_idempotent_slugify(title: str, max_len: int) -> None:
    result = slugify.slugify(title=title, max_len=max_len)
    repeat = slugify.slugify(title=result, max_len=max_len)
    assert result == repeat, (result, repeat)

生成された st.integers() は運用値ではない

この下書きをそのまま走らせると通ります。

$ pytest test_gw.py -q
.                                                                        [100%]
1 passed in 0.08s

max_len=st.integers() が負の値も巨大な値も含むぶん、31文字前後という狭い帯に当たらないためです。運用で使っている値に絞ると結果が変わります。

@given(title=st.text(alphabet=st.sampled_from("ab-"), min_size=31))
def test_idempotent_slugify(title):
    result = slugify(title, max_len=30)
    assert result == slugify(result, max_len=30)
E       AssertionError: assert 'aaaaaaaaaaaa...aaaaaaaaaaaa-' == 'aaaaaaaaaaaa...aaaaaaaaaaaaa'
E       Failing test case: test_idempotent_slugify(
E           title='aaaaaaaaaaaaaaaaaaaaaaaaaaaaa-a',
E       )

末尾ハイフンが残るせいで、2回通すと結果が変わる。同じ性質を書いていても、ストラテジが運用値から離れていれば赤になりません。ghostwriter が埋めてくれるのは関数シグネチャまで。値の範囲は自分で入れ直します。

ローカルと CI では設定が入れ替わる

deadline の初期値は200ミリ秒

1ケースあたりの実行時間に上限があります。API 呼び出しや DB アクセスを含むテストは、ローカルだとここで落ちます。

@given(st.integers(min_value=0, max_value=10))
def test_slow_call(n):
    time.sleep(0.25)
    assert n >= 0
E   hypothesis.errors.DeadlineExceeded: Test took 309.69ms, which exceeds
    the deadline of 200.00ms. If you expect test cases to take this long,
    you can use @settings(deadline=...) to either set a higher deadline,
    or to disable it with deadline=None.

0.25秒のスリープが309msと報告されています。

CI では ci プロファイルが自動で有効になる

同じテストを CI=true で走らせると通ります。

$ CI=true pytest test_ci.py -q -s
  deadline      = None
  derandomize   = True
  database      = None
  print_blob    = True
2 passed in 3.54s

API Reference の “Built-in profiles” が扱う仕組みです。Hypothesis は CI / GITHUB_ACTIONS / GITLAB_CI / CIRCLECI などの環境変数を検出して、組み込みの ci プロファイルへ自動で切り替えます。settings.load_profile() を1行も書かなくても切り替わる。

設定default(ローカル)ci(CI 環境で自動)
max_examples100100
deadline200msNone
derandomizeFalseTrue
database.hypothesis/examplesNone
print_blobFalseTrue
suppress_health_checkなしtoo_slow

「deadline が CI を落とす」という心配は要りません。むしろ逆で、ローカルだけが200msで落ちます。同じテストがローカルで赤、CI で緑になる原因はここ。

もうひとつの差が derandomize=True。CI では乱数がテスト関数名のハッシュから決まるので、同じコミットなら毎回同じ入力が流れます。再実行で結果が変わる不安定さは消える代わりに、探索の幅も毎回同じ。CI で入力を変えたいなら max_examples を上げるか、夜間ジョブでプロファイルを分けます。

# conftest.py
import os
from hypothesis import settings

settings.register_profile("nightly", max_examples=2000, derandomize=False)
settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "default"))

CI で出た反例は blob で持ち帰る

CI では database=None なので .hypothesis/examples に反例が残りません。代わりに print_blob=True が効いて、失敗ログに再現用のデコレータが出ます。

E       Failing test case: test_slug_has_no_trailing_hyphen(
E           title='aaaaaaaaaaaaaaaaaaaaaaaaaaaaa-a',
E       )
E
E       You can reproduce this test case by temporarily adding
        @reproduce_failure('6.163.0', b'AXicmy+fiA/oJgIA1AgMSg==')
        as a decorator on your test function

この行をコピーして手元のテストに貼ると、同じ入力が再生されます。

from hypothesis import given, reproduce_failure, strategies as st


@reproduce_failure('6.163.0', b'AXicmy+fiA/oJgIA1AgMSg==')
@given(st.text(alphabet="ab-", min_size=25))
def test_slug_has_no_trailing_hyphen(title):
    assert not slugify(title).endswith("-")
Failing test case: test_slug_has_no_trailing_hyphen(
    title='aaaaaaaaaaaaaaaaaaaaaaaaaaaaa-a',
)
1 failed in 0.13s

blob はバージョン文字列を含むので、Hypothesis を上げると使えなくなります。恒久的に残すなら @example(...) に書き換える。ただしドキュメントに制約があります。

Note that explicit examples added by @example do not shrink.

貼るのは縮小済みの反例なので実害はありません。手で書いた大きい入力を @example に入れると、失敗時にそのままの形で報告されます。

統計を見て試行回数を決める

--hypothesis-show-statistics で、何回試して何回が有効だったかが出ます。

$ pytest test_stats.py -q --hypothesis-show-statistics
  - during generate phase (0.02 seconds):
    - Typical runtimes: < 1ms, of which < 1ms in data generation
    - 100 passing, 0 failing, and 0 invalid test cases

  - Stopped because settings.max_examples=100

Stopped because settings.max_examples=100 は、上限に当たって探索を打ち切ったという意味。invalid が多ければ assume を削る。1ケースが1ms未満なら、max_examples を上げる余地が残っています。

まとめ

  • @given は入力の列挙をやめ、入力によらず成り立つ条件だけを書く
  • 反例は shrink されて最小形で報告される。127文字が31文字まで縮み、108回の試行で60回成功
  • 6.163.0 の失敗行は Failing test case:Falsifying example ではない
  • st.text() は短い文字列に偏る(最頻2文字、31文字以上は0.5%)。境界を狙うなら min_sizealphabet で寄せる
  • hypothesis write は型ヒントから下書きを出す。[cli] extra が必要で、ストラテジは運用値に絞り直す
  • CI 環境では組み込みの ci プロファイルが自動で有効になり、deadline が外れて database が消える。反例は @reproduce_failure の blob で持ち帰る

pytest のフィクスチャとモックはそのまま併用できます。@given@pytest.mark.parametrize も同じ関数に付けられる。例示テストの置き換えではありません。長さと文字種の総当たりだけを任せる、という足し方です。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次