プロパティベーステスト

ランダムな入力を大量に生成し、プログラムの不変条件が常に成り立つことを検証するテスト手法

テスト品質

プロパティベーステストとは

プロパティベーステスト (PBT) は、ランダムな入力を大量に生成し、プログラムの不変条件 (プロパティ) が常に成り立つことを検証するテスト手法である。起源は Koen Claessen と John Hughes が ICFP 2000 で発表した Haskell の QuickCheck である。書き手は満たすべき性質を宣言するだけで、値の生成と大量の試行はツールが受け持つ、という分担がここで示された。TypeScript では fast-check が同じ考え方を実装している。

例ベーステスト vs プロパティベーステスト

この 2 つは排他ではない。違いはテストの中で人が何を決めるかにある。例ベースでは入力と期待値の対を人が選び、プロパティベースでは入力の範囲と成り立つべき性質を人が決めて、個々の値はツールが作る。

観点例ベースプロパティベース
入力開発者が選んだ具体値ランダムに自動生成
テスト数数個〜数十個既定 100 回・設定で増減
エッジケース開発者が思いつく範囲自動的に発見
検証具体的な期待値不変条件 (プロパティ)

fast-check での例

fast-check では fc.property で性質を宣言し、fc.assert が生成した入力でそれを繰り返し試す。生成器 (fc.arrayfc.integer 等) の組み合わせが入力の範囲になる。試行回数は既定 100 回で、fc.assert の第 2 引数 numRuns で変えられる。なお下の 1 本目は、長さと「元の配列に含まれる要素か」しか見ていない。要素を 1 つ重複させて別の要素を落とすような壊れ方 ([1, 2] から [1, 1]) はこの 2 つを通り抜けるので、性質の緩さはそのままテストの穴になる。

import fc from 'fast-check';

// プロパティ: ソートした配列は元の配列と同じ要素を持つ
test('sort preserves elements', () => {
  fc.assert(
    fc.property(fc.array(fc.integer()), (arr) => {
      const sorted = [...arr].sort((a, b) => a - b);
      expect(sorted.length).toBe(arr.length);
      expect(sorted.every(x => arr.includes(x))).toBe(true);
    })
  );
});

// プロパティ: JSON.parse(JSON.stringify(x)) === x (ラウンドトリップ)
test('JSON roundtrip', () => {
  fc.assert(
    fc.property(fc.jsonValue(), (value) => {
      expect(JSON.parse(JSON.stringify(value))).toEqual(value);
    })
  );
});

代表的なプロパティ

最初の壁は、性質を思いつけるかどうかである。手掛かりは、出力そのものを予測せずに書ける関係を探すことにある。下は対象を変えても使い回せる型である。

プロパティ
ラウンドトリップencode → decode で元に戻る
冪等性f(f(x)) === f(x)
不変条件ソート後は昇順
可換性a + b === b + a
モデルベース実装とリファレンスが同じ結果

シュリンキング

失敗した入力は、たいてい原因と無関係な要素を抱えている。シュリンキングは、失敗した値から生成規則に沿ってより小さい候補を作り、失敗が再現する限り縮め続ける仕組みである。

テスト失敗: [42, -7, 0, 100, -3] で失敗
  ↓ シュリンキング (最小の反例を探索)
最小反例: [-1] で失敗
→ 負の数の処理にバグがある

そのため報告に出るのは最初に失敗した入力ではなく、縮小し終えた値である。上の形は実際に再現できる。「配列の全要素が 0 以上」という性質を整数配列に対して試すと 4 回目の試行で失敗し、29 回の縮小を経て [-1] が反例として報告された。報告には seed と探索経路も付くので、同じ生成と縮小をあとから再現できる。

バリデーションを生成器で表す

入力値の検査は、有効な値の範囲を生成器でそのまま書けるので相性が良い。fc.record で項目ごとに生成器を割り当てると、その範囲内のどの組み合わせでも通ることを試せる。範囲外の値を生成して必ず弾かれることも、同じ形でもう 1 本書いておく。通る側だけ書くと、何でも通す実装が合格してしまう。

test('全ての有効な注文がバリデーションを通過', () => {
  fc.assert(
    fc.property(
      fc.record({
        productId: fc.uuid(),
        quantity: fc.integer({ min: 1, max: 100 }),
        email: fc.emailAddress(),
      }),
      (order) => {
        expect(validateOrder(order).success).toBe(true);
      }
    )
  );
});

いつ使うか

向くかどうかは、入力を変えても常に成り立つと言い切れる性質を挙げられるかで決まる。

対象書くプロパティ理由
パーサーやシリアライザーencode してから decode すると元に戻る正解を一件ずつ書かなくても、生成した入力そのものが期待値になる
入力値のバリデーション有効な範囲で生成した値は必ず通り、範囲外は必ず弾かれる開発者が選んだ具体値では見落とす境界の 1 つ違いを、生成側の範囲指定で機械的に踏ませられる
ソートや集計などのアルゴリズム結果が昇順である、合計が入力と一致するといった不変条件出力そのものを予測せずに正しさを表現でき、失敗時はシュリンキングで最小の反例まで縮めて原因を絞れる
画面操作の UI テスト例ベースで書く期待する見た目や遷移は具体的な操作手順とセットで決まり、ランダム入力に対して常に成り立つ性質として表しにくい

最初の 1 本は、すでにバグが出た関数に対して書くのが早い。反例が出れば性質の書き方が合っていたと分かり、出なければ性質が緩すぎるか生成範囲が狭すぎるという手掛かりになる。既存の例ベースのテストを置き換える必要はなく、境界の探索だけをプロパティ側に任せる形で足していけばよい。

この記事は役に立ちましたか?

関連用語

関連する記事