100 Test Runs Is a Lie: Property-Based Testを実際にどうサイジングすべきか

Property-based testをデフォルトの100例で実行しているなら、最悪の両方を手に入れている。CIは必要以上に遅く、それでも大事なバグは見逃している。

この数字に魔法はない。Hypothesisを含むほとんどのライブラリが100をデフォルトにしているのは、安全に感じられるきりの良い数字だからだ。しかし「安全に感じられる」はtesting strategyではない。

Property-based testingが実際に約束するもの

Property-based testingはunit testの脚本をひっくり返す。入力と期待出力を手書きする代わりに、propertyを定義する。つまり、常に成り立つはずのルールだ。Frameworkがそのルールを破る入力を生成する。

from hypothesis import given, strategies as st

@given(st.lists(st.integers()))
def test_reversing_a_list_twice_gives_the_original(lst):
    assert lst == list(reversed(list(reversed(lst))))

Frameworkはこの関数をランダムな整数リストで何度も実行する。Counterexampleを見つければ、入力をshrinkして、なお失敗する最小のバージョンに縮小する。47要素のリストがバグを引き起こしても、デバッグには役立たない。3要素のリストはゴールドだ。

これは強力だ。同時に確率的だ。Property-based testingは正しさを証明できない。バグが存在しない自信を高めるか、バグがあれば見つけることしかできない。その確率的な性質こそが、実行回数をこれほど重要にしている。

なぜ100は恣意的なのか

100がどこから来たのか、正直に語ろう。Hypothesisでは、2015年に「きりの良い数字で、ほとんどのバグを捉えつつテストを耐え難く遅くしない」という理由で選ばれたデフォルトだ。統計的妥協ではなく、社会的妥協だった。

バグを見つける確率は、2つの要素に依存する。Input spaceの中でそのバグがどれだけ一般的か、そしてどれだけサンプルを取るかだ。もしバグが長さ20を超える回文でのみ発火し、回文が全リストの0.01%だとしたら、100回の実行でそれを捉える確率はおおよそ1%だ。それはテストではない。宝くじだ。

ほとんどのバグはそこまで珍しくない。多くのpropertyは空リスト、単一要素、単純な重複で破綻する。Well-tuned generatorはそれらを素早く捉える。しかし100というデフォルトは、generatorが完璧でバグが浅いことを前提にしている。両方とも間違っている。

統計的に、実行回数が実際に買えるもの

Bug discoveryを「バグの出現確率がpのinput spaceから復元抽出するモデル」とみなせば、n回実行後もバグを見逃す確率は(1 - p)^nとなる。

p = 0.01の場合、100回の実行でバグを見逃す確率は37%だ。p = 0.001なら、100回で90%の確率で見逃す。0.1%のバグを99%のconfidenceで捉えるには、約4,600回の実行が必要だ。

import math

def runs_for_confidence(p, confidence=0.99):
    """Returns the runs needed to catch a bug with probability `p`
    at the given confidence level."""
    return math.ceil(math.log(1 - confidence) / math.log(1 - p))

print(runs_for_confidence(0.01))    # 459
print(runs_for_confidence(0.001))   # 4603
print(runs_for_confidence(0.0001))  # 46050

これは人々を不快にさせる部分だ。珍しいバグに対して高いconfidenceを得たければ、数万回の実行が必要だ。誰もCIでそれを待ちたくない。

Shrinkingがコスト構造を変える

100回のデフォルトは、shrinkingが今ほど優秀になる前に設定された。Modern property-based testing frameworkはバグを見つけるだけでなく、minimal bugを見つける。

つまり、countだけでなくbudgetで考えられる。1,000例を実行し、847回目でバグを見つけたとしよう。Shrinkingはcounterexampleを最小化するために、さらに200〜300回の実行を要するかもしれない。1つのバグに対する総コストは1,100回以上だ。しかし、10,000例を実行して何も見つからなければ、それは安心のための10,000回だ。

秘訣はdiscoveryとvalidationを分離することだ。CIでは小さく高速なsuiteを実行し、即座のフィードバックを得る。Overnightやrelease branchでは、より大きく遅いsuiteを実行し、より深いconfidenceを得る。

from hypothesis import given, settings, strategies as st
import json

# Fast feedback in CI
@given(st.dictionaries(st.text(), st.integers()))
@settings(max_examples=100)
def test_json_roundtrip_fast(d):
    assert json.loads(json.dumps(d)) == d

# Deeper confidence on main
@given(st.dictionaries(st.text(), st.integers()))
@settings(max_examples=5000, deadline=None)
def test_json_roundtrip_thorough(d):
    assert json.loads(json.dumps(d)) == d

これは速度の話だけではない。Information densityの話だ。通過した100回のテストは、ほとんど何も教えてくれない。通過した5,000回のテストは、わずかに多くを教えてくれる。失敗した100回のテストは、どこを調べればいいか正確に教えてくれる。

Property-based testを3つのtierに分ける方法

私たちの経験では、すべてのpropertyを等しく扱うのをやめるのが最善のアプローチだ。3つのtierに分ける。

Fast propertiesはすべてのpull requestで実行する。機械的なものだ。Round-trip serialization、deduplicationのidempotency、データ構造の基本的なinvariantなど。100〜200例を実行する。1秒以内に終わる。

Deep propertiesはmainへのすべてのmergeで実行する。複雑なstate machine、event processing pipeline、組合せ爆発を伴うものを対象とする。2,000〜10,000例を実行する。数分かかるが、数時間ではない。

Exploratory propertiesはリリース前に手動で実行する。max_examplesを50,000以上に上げ、マシンに任せながらchangelogをレビューする。この方法で、いくらunit testingを積んでも捉えられないrace conditionやinteger overflowのedge caseを見つけてきた。

推測の代わりにすべきこと

max_examplesを一度設定して忘れるダイヤルとして扱うのをやめろ。それはframeworkではなく、propertyに属する設定として扱え。

書くすべてのpropertyに対して、3つの質問をしろ。

このテストの実行コストはどれだけか?例が1つ50msかかるなら、10,000回は8分だ。5msなら1分未満だ。

見逃した場合のバグの深刻度はどれだけか?Log messageのformatting bugと、payment pipelineのdata corruption bugは同じではない。

発火条件はどれだけ珍しいか?バグが閏年でのみ発生する場合、2つのUUIDが衝突した場合、またはちょうどINT_MAXの場合、より多くの実行か、より賢いgeneratorが必要だ。

Smarter generatorsはほぼ常にmore runsに勝つ。JSON parserをテストするなら、ランダムな文字列を生成してparseを祈るな。Valid objectを生成し、それをmutateする。

from hypothesis import given, settings, strategies as st
import json

# Bad: most random strings aren't valid JSON
@settings(max_examples=10000)
@given(st.text())
def test_parse_json_bad(s):
    try:
        json.loads(s)
    except json.JSONDecodeError:
        pass  # Most inputs hit this immediately

# Good: generate valid objects, then edge cases
@settings(max_examples=500)
@given(st.dictionaries(st.text(), st.integers()))
def test_parse_json_good(d):
    assert json.loads(json.dumps(d)) == d

500回のgood generatorは、10,000回のbad generatorに勝る。常にだ。

Property-based testのサイジングに関するよくある質問

More runsは常にbetter coverageを意味するのか?

必ずしもそうではない。Property-based testingには、従来の意味でのcoverage metricはない。More runsはバグを見つける確率を上げるが、diminishing returnsは急速に訪れる。100から200への倍増は意味がある。10,000から20,000への倍増はめったに意味がない。

Fuzzingはどうか?あれは数百万回の実行で行うproperty-based testingではないのか?

Fuzzingは隣接だが異なる。Fuzzerは通常、domainに対するsemantic understandingなしに数百万のinputを実行する。Property-based testingはstructured generatorとshrinkingを使う。PBTをsmart fuzzingと考えるか、fuzzingをbrute-force PBTと考えるかは自由だ。実行回数の計算は異なる。なぜなら、1回あたりのコストと1回あたりの情報量が同じではないからだ。

CIではmax_examplesを高くすべきか、低くすべきか?

CIでは高く、local developmentでは低く。ラップトップは速度のため。CIはconfidenceのため。Settings profileや環境変数で切り替えろ。

import os
from hypothesis import settings

CI = os.environ.get("CI", "false").lower() == "true"

settings.register_profile("ci", max_examples=5000, deadline=None)
settings.register_profile("dev", max_examples=100)

settings.load_profile("ci" if CI else "dev")

Generatorが十分良いかどうかをどう知る?

max_examplesを非常に高く、例えば50,000に設定してテストを実行し、coverage reportを見る。Branchが不足していれば、generatorがそれらをexercisingしていないということだ。実行回数を下げる前にgeneratorを直せ。

完璧な実行回数を探すのをやめて、測定を始めろ

Property-based testingに普遍的に正しい実行回数は存在しない。存在するのは、あなたのproperty、generator、CI予算、防ごうとするバグのコストに対して正しい数字だけだ。

どうしてもなら100から始めろ。しかし、critical pathを守るpropertyでは増やし、sanity checkに過ぎないpropertyでは減らせ。テストの実行時間を測定しろ。Generatorをprofileしろ。そして忘れるな:100回通過したproperty-based testは証明ではない。ただの証拠だ。

さらに深く知りたい場合、Hypothesisのtest statisticsとtargeted property-based testingに関するドキュメントを読む価値がある。hypothesis CLIは、テストがどの例で時間を費やしているか正確に示せる。ダイヤルを上げるか下げるか決めるとき、最初に見るべき場所だ。