単体テストがカバーできるのは、あなたが思いついたケースだけ
reverse 関数を書いた。[1, 2, 3] と [4, 5, 6, 7] でテストして、パスした。リリースした。
ユーザーが1要素のスライスを渡すと、関数はそれを黙って落とす。Issueが立つ。テストファイルを見つめながら、こんな当たり前のことにどうして気づかなかったのかと思う。
見逃したのは、例ベースのテストが予想したバグしか捕まえられないからだ。テストスイートのすべての assert_eq! は、失敗がどこに潜むかという推測に過ぎない。プロパティベースのテストは、その推測をランダムデータと数学的不変条件で置き換える。1要素のバグも見つける。空ベクトルのバグも、整数オーバーフローのエッジケースも、存在すら知らなかったUnicodeのコーナーケースも見つける。
例と期待値ではなく、ランダム入力と不変条件
プロパティベースのテストは発想を覆す。入力を選んで正確な出力をアサートするのではなく、すべての入力に対して成り立つべき性質を定義し、テストフレームワークが数百のランダム入力を生成して崩そうとする。
古典的な例はリストの反転だ。性質は「[1, 2, 3] の反転は [3, 2, 1]」ではない。性質は「リストを2回反転すると元のリストに戻る」だ。この不変条件はあらゆるリストに成り立つので、フレームワークは何か壊れるまで、あるいは反復上限に達するまでランダムなリストを投げつける。
Rustにはこのための成熟したライブラリが2つある。quickcheck はHaskellから移植された最初の人気オプションだ。proptest はより新しいアプローチで、決定的な機能を持つ:自動縮小(shrinking)。proptest が失敗する入力を見つけると、100要素のベクトルを出力してデバッグをあなたに任せるのではない。入力を単純化して、最小の反例を見つける。これが、失敗したテスト出力を読むことと、実際にバグを理解することの違いだ。
縮小(Shrinking)が本当のバグを見つける仕組み
縮小は秘密のソースだ。proptest が47個の整数のランダムベクトルを生成して、性質が失敗したとしよう。バグは47番目の要素ではない。バグはおそらく空ベクトルについてか、符号の変化についてか、重複値についてだ。
proptest はこれを知っている。要素を削除してみる。値をゼロにしてみる。ベクトルをどんどん小さくしてみる。より小さい入力でもテストが失敗すれば、縮小を続ける。より単純なすべての変異がパスするまで止まる。結果は最小反例だ。
これはあって嬉しい機能ではない。実際にプロパティベースのテストが使える理由だ。縮小がなければ、再び針の山をデバッグすることになる。縮小があれば、針を手に入れられる。
本物のバグ、本物のテスト
巧妙なバグを含む reverse 関数がある。テストが見つける前に、あなたが見つけられるかどうか試してみよう。
fn reverse<T: Clone>(xs: &[T]) -> Vec<T> {
let mut rev = Vec::with_capacity(xs.len());
for i in 1..xs.len() {
rev.push(xs[xs.len() - i].clone());
}
rev
}
レンジが 0 ではなく 1 から始まっている。空でないリストならどれでも、最初の要素が黙って落とされる。[1, 2, 3] という単体テストは、作者が出力が反転しているように見えることだけを確認して、長さが正しいかまでは確認しなかった場合、パスしてしまうかもしれない。しかし性質は即座にそれを捕まえる。
Cargo.toml に proptest を追加しよう:
[dev-dependencies]
proptest = "1.6"
それから性質を書く:
use proptest::prelude::*;
proptest! {
#[test]
fn reverse_is_its_own_inverse(xs in any::<Vec<i32>>()) {
let rev = reverse(&xs);
let rev_rev = reverse(&rev);
prop_assert_eq!(xs, rev_rev);
}
}
cargo test を実行すると、出力がすべてを語る:
thread 'reverse_is_its_own_inverse' panicked at 'assertion failed: `(left == right)`
left: `[0]`,
right: `[]`'
proptest は失敗したランダムベクトルを1要素、[0] まで縮小した。最初の呼び出しは [] を返す。2回目の呼び出しも [] を返す。性質は [] == [0] を要求し、それは失敗する。入力が最小になると、バグは明らかだ。
構造的な性質も直接テストできる:
proptest! {
#[test]
fn reverse_preserves_length(xs in any::<Vec<i32>>()) {
prop_assert_eq!(xs.len(), reverse(&xs).len());
}
}
これも同じ最小入力で失敗するが、さらに明確なエラーを出す:出力の長さは 0 で、1 ではない。
なぜ通常の単体テストがまだ必要なのか
プロパティベースのテストは例ベースのテストの置き換えではない。補完だ。
単体テストは意図を文書化する。新しい開発者が reverse(&[1, 2, 3]) が [3, 2, 1] と等しくなるべきだと読んだら、その関数が何をすべきか学ぶ。プロパティテストは「この不変条件が成り立つ」と言うが、通常のケースで出力がどう見えるかは教えてくれない。
性質は例よりも書くのが難しい。ステートマシン、副作用、I/O は純粋関数にはきれいに対応しない。proptest には複雑な型用のストラテジーがあるが、それらを繋ぎ合わせるのは、いくつかの assert_eq! 呼び出しよりもっと考えが必要だ。
時間コストもある。プロパティテストは1000回の反復を実行するかもしれない。大規模なコードベースでは、それが積み重なる。プロパティテストはCIで実行し、開発中のすべてのキーストロークで実行するのではない。
ランダムなテストでも再現性は保てる
ランダム性は人を不安にする。テストが時々しか失敗しないなら、それはコードのせいか、テストのせいか?
proptest はこれを、失敗したシードを永続化することで対処する。性質が失敗すると、シードと縮小された入力を proptest-regressions/ のファイルに保存する。次回の実行では、新しいランダムデータを生成する前に、その正確なケースを再生する。不安定なプロパティテストは、ほぼ常にバグのある性質であり、フレームワークのせいではない。
厳密な決定性が必要なら、テスト設定でシードを固定できる。ほとんどのチームは面倒を見ない。回帰ファイルで十分だ。
プロパティテストが報われる場所
プロパティベースのテストは、コードに明確な数学的不変条件と広大な入力空間があるときに、オーバーヘッドに見合う価値がある。
ソートは教科書的な例だ。性質は単純だ:出力はソートされていて、入力の順列で、同じ長さを持つ。バグのあるソートは手書きの例ではパスするかもしれないが、重複要素を含むランダムデータでは失敗する。
パーサとシリアライザもまた絶好の場だ。parse(serialize(x)) == x のような往復の性質は、特定のバイト列でのみ現れるエンコーディングバグを捕まえる。
算術、コレクション、状態遷移を含むものは何でも恩恵を受ける。外部API、厳密なタイミング、人間の判断を含むものはそうではない。
1つの性質から始めよう
テストスイートを書き換える必要はない。明確な不変条件を持つ純粋関数を1つ選ぼう。dev-dependencies に proptest を追加する。1つの性質を書く。実行してパスするのを見て、それから巧妙なバグを入れて、数秒でデバッグできるように失敗を縮小するのを見よう。
ランダムなテストが、自分では絶対に例を書かなかったバグを見つけるのを一度見たら、もっと頻繁に性質に頼るようになるだろう。すべてのテストにではない。重要なテストにだけ。