単体テストがカバーできるのは、あなたが思いついたケースだけ

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.tomlproptest を追加しよう:

[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-dependenciesproptest を追加する。1つの性質を書く。実行してパスするのを見て、それから巧妙なバグを入れて、数秒でデバッグできるように失敗を縮小するのを見よう。

ランダムなテストが、自分では絶対に例を書かなかったバグを見つけるのを一度見たら、もっと頻繁に性質に頼るようになるだろう。すべてのテストにではない。重要なテストにだけ。