記事タイトルから 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)) == x | URL エンコードや 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_examples | 100 | 100 |
deadline | 200ms | None |
derandomize | False | True |
database | .hypothesis/examples | None |
print_blob | False | True |
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_sizeとalphabetで寄せるhypothesis writeは型ヒントから下書きを出す。[cli]extra が必要で、ストラテジは運用値に絞り直す- CI 環境では組み込みの
ciプロファイルが自動で有効になり、deadline が外れて database が消える。反例は@reproduce_failureの blob で持ち帰る
pytest のフィクスチャとモックはそのまま併用できます。@given と @pytest.mark.parametrize も同じ関数に付けられる。例示テストの置き換えではありません。長さと文字種の総当たりだけを任せる、という足し方です。

