プロパティベーステストの系譜 — QuickCheckからHypothesisまで

2000年、クラーセンとヒューズがHaskell向けQuickCheckを発表し、「具体例でなく性質を書く」という新しいテストの発想を切り開いた。ジェネレータとシュリンキング、Hypothesis・fast-check・proptestへの展開、そしてファジングとの関係までを辿る。

testinghistoryproperty-based-testingquickcheckfuzzinghaskell

前回見たモック(第6記事)は、「特定の呼び出しが正しく行われたか」という具体的な振る舞いを検証する技法だった。ほとんどの単体テストも同様に、「この入力に対してこの出力になる」という具体例(example) を1つずつ書く。だが、コードが満たすべき性質——「ソートした後もリストの長さは変わらない」「シリアライズしてデシリアライズしたら元に戻る」——を先に定義し、その性質を満たすかどうかをツールが大量のランダムな入力で検証する、という全く異なるアプローチがある。プロパティベーステスト(property-based testing、PBT) である。

QuickCheck論文 — 2000年、ICFP

2000年、スウェーデン・ヨーテボリのチャルマース工科大学で、コーエン・クラーセン(Koen Claessen)とジョン・ヒューズ(John Hughes)は、ACM SIGPLAN International Conference on Functional Programming(ICFP 2000) で論文 “QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs” を発表した。この論文がプロパティベーステストという概念を世に広めた出発点である。

論文で示されたQuickCheckの実装は当初わずか300行程度のシンプルなライブラリだったが、そのアイデアは強力だった。

-- 「リストを逆順にして、もう一度逆順にすると元に戻る」という性質
prop_reverseReverse :: [Int] -> Bool
prop_reverseReverse xs = reverse (reverse xs) == xs

プログラマは個別の入出力例ではなく、満たされるべき性質(property) を関数として書く。QuickCheckはその性質に対して、型情報をもとにランダムな入力を大量に生成し、反例が見つかるまでテストを繰り返す。

ジェネレータとシュリンキング

QuickCheckが確立した2つの中核概念が、プロパティベーステストを実用的な技法として機能させている。

ジェネレータ(generator) は、テスト対象の型(整数・文字列・リスト・独自のデータ型)に応じてランダムな値を生成する仕組みである。QuickCheckは型クラス(Arbitrary)を通じて標準の型には自動でジェネレータを提供し、独自の型にはユーザーが定義できるようにした。

シュリンキング(shrinking、縮小) は、反例が見つかったときにその入力をより単純な形へ自動的に縮小していく仕組みである。要素数47個のリストで失敗が再現したとしても、そのままでは人間には読みにくい。QuickCheckは性質が壊れたまま入力を縮小し続け、最終的に「これ以上単純化できない最小の反例」を提示する。この「ジェネレータ+シュリンキング」の組み合わせこそが、QuickCheck系ツール共通のアーキテクチャである。

生成された失敗入力: [3, -17, 0, 256, -1, 42]
                     ↓ shrink
                  [0, -1]
                     ↓ shrink
                  [-1]          ← これ以上小さくできない最小反例

Quviq QuickCheck — 商用化と並行システムへの応用

ジョン・ヒューズは2006年、QuickCheckの技術を商用化するためにQuviq社を設立し、Erlang向けの商用版QuickCheckを提供した。並行処理・分散システムの複雑なバグ(レースコンディション、ノード障害時の不整合など)を検出する用途で通信・自動車業界の実システムに適用され、実際に深刻なバグを多数発見した実績で知られる。

Erlang版は、ステートフルなシステム(プロトコル実装やデータベースなど)を「状態機械としてモデル化し、ランダムに生成した操作列をモデルと実システムの両方に適用して整合性を検証する」というステートフルプロパティテストの手法を発展させたことでも重要である。ランダムに生成された「操作の順序」自体がバグを誘発することが多く、単発の入力テストでは見つからない競合状態やタイミング依存のバグを発見できる点が特徴である。

各言語への展開

QuickCheckのアイデアはHaskellの外へ急速に広がり、現在ではほぼすべての主要言語に「QuickCheck系」のライブラリが存在する。

言語 ライブラリ 特記事項
Haskell QuickCheck 元祖(Claessen & Hughes, 2000)
Erlang Quviq QuickCheck 商用、並行・分散システム向け
Scala ScalaCheck リカード・ニルソン(Rickard Nilsson)が開発
Python Hypothesis デイビッド・R・マキヴァー(David R. MacIver)が開発
JavaScript/TypeScript fast-check Jest等と組み合わせて使われる
Rust proptest / quickcheck proptestはHypothesis系の設計思想を採用
Java jqwik JUnit5との統合を重視
C#/F# FsCheck .NET向け

Hypothesisの独自性

PythonのHypothesisは、単にQuickCheckを移植したのではなく、いくつかの重要な進化を遂げている。「生成された値の履歴をデータベースに保存し、次回以降のテスト実行で過去に見つかった反例を優先的に再現する」という統合的な仕組みや、シュリンキングの精緻さに特徴がある。

from hypothesis import given, strategies as st

@given(st.lists(st.integers()))
def test_reverse_twice_is_identity(xs):
    assert list(reversed(list(reversed(xs)))) == xs

このテストは1つの入力例ではなく「任意の整数リスト」全体に対して実行され、失敗する入力が見つかればシュリンキングを経て最小の反例が報告される。RustのproptestはHypothesisに触発されたことを明言しており、「戦略(strategy)」という語彙や統合シュリンキングの設計を引き継いでいる。JavaScriptのfast-checkも同様の思想で書かれる。

import fc from "fast-check";

fc.assert(
  fc.property(fc.array(fc.integer()), (xs) => {
    const roundTripped = JSON.parse(JSON.stringify(xs));
    return JSON.stringify(roundTripped) === JSON.stringify(xs);
  })
);

いずれの例も、テストコードの中に「具体的な入力値」が一切現れない点が、通常の単体テストとの最大の違いである。

シュリンキングの2つの流儀

シュリンキングの実装方式には大きく2つの流儀がある。

  • 型ベースシュリンキング: オリジナルのQuickCheckが採用した方式で、各型ごとに「どう縮小するか」を定義する(整数なら0に近づける、リストなら要素を減らす、など)。実装がシンプルな反面、型ごとに縮小ルールを個別に書く必要がある。
  • 統合シュリンキング: HypothesisやRustのproptestが採用する方式で、値の生成過程(乱数列の消費履歴)そのものを縮小の対象にする。ジェネレータの組み合わせが複雑でも、生成ロジックを変更せずに一貫した縮小が効く利点がある。

プロパティの型 — 何を書けばよいか

プロパティベーステストで最も難しいのは、ツールの使い方ではなく「どんな性質を書けばよいか」を見極めることである。実務でよく使われる型には次のものがある。

  • ラウンドトリップ(round-trip): decode(encode(x)) == x のように、変換して戻すと元に戻ることを検証する。シリアライズ・パーサ・エンコーディングの検証で頻出する。
  • 不変条件(invariant): 「ソート後も要素数は変わらない」「スタックにpushしてpopすると同じ値が返る」など、操作の前後で保たれるべき性質。
  • モデルベース(model-based): 単純だが遅い/明らかに正しい「モデル実装」を用意し、実装と出力を突き合わせる。しばしば差分テスト(differential testing) とも呼ばれる。
  • オラクル問題との関係: 「正しい出力が何であるか」を判定する基準(テストオラクル)を用意できない場合でも、「入力と出力の関係が満たすべき性質」なら定義できることが多い。プロパティベーステストは、厳密なオラクルがなくても検証を可能にする手法として位置づけられる。

ファジングとの合流

自動生成した入力でプログラムを検証するという発想は、ファジング(fuzzing) とも深く関係している。PBTが「意味のある性質が成り立つか」という論理的な正しさを検証するのに対し、ファジングは主に「クラッシュしないか」「メモリ安全性違反が起きないか」という頑健性を検証する。2010年代以降主流になったcoverage-guided fuzzing(カバレッジ誘導型ファジング) は、実行時にどのコードパスを通ったかを計測し、新しいコードパスを通った入力を優先的に変異させることで探索を効率化する。

ツール 特徴
AFL(American Fuzzy Lop) Michal Zalewskiが開発し2013年公開。coverage-guided fuzzingを実務に広めた先駆的ツール
libFuzzer LLVMプロジェクトに組み込まれたインプロセス型のファジングエンジン
OSS-Fuzz Googleが2016年12月に発表した、オープンソースプロジェクト向け継続的ファジングサービス
go-fuzz / go test -fuzz Dmitry Vyukovが開発。Go 1.18(2022年)で言語標準ツールチェーンにネイティブ統合された
// Go 1.18以降のネイティブファジングの例
func FuzzParseURL(f *testing.F) {
    f.Add("https://example.com/path")
    f.Fuzz(func(t *testing.T, input string) {
        u, err := url.Parse(input)
        if err != nil {
            return // パース失敗は許容
        }
        if _, err := url.Parse(u.String()); err != nil {
            t.Errorf("re-parse failed for %q: %v", u.String(), err)
        }
    })
}

両者は排他的ではなく、近年は「構造化された入力を生成しつつカバレッジフィードバックも使う」構造認識型ファジングも広がっており、境界は次第に曖昧になりつつある。

実務での採用状況

プロパティベーステストは強力だが、実務への浸透度はxUnit系の単体テストほど高くない。「性質」を定式化すること自体が設計スキルを要求し、具体例を書くよりハードルが高いこと、実行時間が長くなりがちでCI環境でのシード固定・再現性の管理が必要になることが理由として挙げられる。一方で、パーサ・シリアライザ・暗号関連コード・並行処理・不変条件が明確なドメインロジックでは高い効果を発揮することが広く認められており、Hypothesisの普及以降、Pythonエコシステムを中心に採用が拡大している。

この記事から次の記事へ

プロパティベーステストとファジングは、「入力の側」を自動生成することでテストの網羅性を高める技法だった。次に見るのは、その関心をシステムの「外側」——実際にブラウザを操作するユーザーの視点——へと移した領域である。2004年のSeleniumから始まり、Puppeteer、Cypress、Playwrightへと続くE2Eテストの世代交代を辿る。

参考文献

  • Koen Claessen, John Hughes, “QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs”(ICFP 2000)
  • Koen Claessen, John Hughes, “Testing Monadic Code with QuickCheck”(2002年)
  • Michal Zalewski, american fuzzy lop(2013年公開)
  • Google, “Announcing OSS-Fuzz: Continuous Fuzzing for Open Source Software”(Google Testing Blog、2016年12月)
  • The Go Programming Language, “Go Fuzzing”(go.dev)
  • Hypothesisドキュメント(hypothesis.works)
← Back to ソフトウェアテストの系譜