Skip to content
Published on

プロパティベーステスト実践 — 例示テストが捕まえられないバグを捕まえる方法

Authors

はじめに — jqwikが再びトップページに上がった日

2026年上半期、Java用プロパティベーステストライブラリのjqwikがHacker Newsのトップページに上がりました。きっかけはライブラリ自体の新機能ではなく、ある開発者が投稿した体験談でした。AIコーディングエージェントが書いた日付処理コードが単体テストをすべて通過したのに、jqwikでプロパティテストを回したところ、数秒でうるう年の境界で壊れる入力を見つけ出したという内容です。コメント欄は「Hypothesisでまったく同じ経験をした」「fast-checkがうちのパーサーの10年物のバグを見つけた」という証言で埋まり、GeekNewsにも翻訳要約が上がって長い議論が続きました。

タイミングは絶妙でした。2026年はAIエージェントがコードのかなりの部分を書く時代です。Claude CodeやCodexのようなツールが数時間の作業を自律的にこなす中で、「そのコードが本当に正しいと人間はどう確認するのか」が業界の核心的な問いになりました。いくつかの例を通過するコードならAIはいくらでも作れます。問題は例と例の間の空白です。プロパティベーステスト(Property-Based Testing、PBT)はまさにその空白を自動的に探索する技法であり、だからこそ今再評価されているのです。

本記事ではPBTの核心概念を整理し、プロパティを発見するパターンカタログを提示した後、Python(Hypothesis)、Java(jqwik)、JavaScript(fast-check)の3言語で動く例を作ってみます。さらに失敗の再現、ステートマシンテスト、CI統合、AIコード検証とのシナジーまで実務の観点から扱います。

例示ベーステストの限界

私たちが毎日書くテストは例示ベースです。

def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0
    assert add(0, 0) == 0

この方式の問題は3つあります。

  1. テストされる入力は作成者が思いついた入力だけです。作成者が思いつかなかった入力(空文字列、Unicode結合文字、整数オーバーフローの境界、うるう年の2月29日)は永遠にテストされません。
  2. 実装者とテスト作成者が同じ人なら、同じ盲点を共有します。実装中に考えつかなかったケースは、テストでも考えつきません。
  3. 例示は「この入力でこの出力」としか言えず、コードが守るべき一般法則を表現できません。

プロパティベーステストは発想を逆転させます。具体的な例の代わりに「すべての有効な入力に対して成立すべき性質(プロパティ)」を宣言し、フレームワークが数百個のランダムな入力を生成して、その性質を破る反例を探します。

核心概念 — プロパティ、ジェネレーター、シュリンキング

PBTの実行フローは次のとおりです。

+--------------+     +--------------+     +-----------+     +--------------+
| プロパティ定義 | --> | ジェネレーター | --> | プロパティ  | --> | 合格: 繰り返し |
| 「すべてのxに  |     | がランダム入力 |     | 検査       |     | (既定100回)  |
|  対してP(x)」 |     | を生成        |     | (assert)  |     +--------------+
+--------------+     +--------------+     +-----+-----+
                                               | 失敗
                                               v
                                    +---------------------+
                                    | シュリンキング:       |
                                    | 失敗を保ったまま      |
                                    | 入力を最小化          |
                                    +----------+----------+
                                               v
                                    「最小反例: x = 0」を報告
  • プロパティ(Property): コードが守るべき一般法則です。「ソート結果の長さは入力と同じ」「エンコード後にデコードすると元に戻る」といったものです。
  • ジェネレーター(Generator): ランダムな入力を作る部品です。整数や文字列のような基本型から「有効なメールアドレスを持つユーザーオブジェクト」のような複合構造まで、組み合わせで作ります。良いフレームワークは境界値(0、-1、空文字列、NaN、最大整数)を意図的に頻繁に混ぜます。
  • シュリンキング(Shrinking): 反例を見つけた後、それを人間が理解できる最小サイズに縮める過程です。「長さ847の文字列で失敗」ではなく「空文字列で失敗」と報告してくれるのがシュリンキングの価値で、実のところPBTツールの品質はシュリンキングの品質で決まります。

プロパティの見つけ方 — パターンカタログ

PBT導入の最大の壁は「うちのコードにどんなプロパティがあるのか分からない」です。幸い、プロパティの大半は次のパターンのいずれかで発見できます。

パターン公式適用例
ラウンドトリップdecode(encode(x)) == xシリアライズ、圧縮、暗号化、パーサー-プリンター
不変条件演算後も常に真である性質ソート後の長さ不変、残高合計の不変
冪等性f(f(x)) == f(x)正規化、重複排除、UPSERT
モデル対照単純な参照実装と結果を比較最適化コード vs ナイーブなコード
交換/結合法則f(a, b) == f(b, a) などマージ、集計、CRDT
事後条件結果が満たすべき条件検索結果はすべてクエリを含む
オラクル比較信頼できる既存実装と比較標準ライブラリ、レガシーシステム
例外不在有効な入力で決してクラッシュしないすべての公開APIの最小プロパティ

このうちラウンドトリップと不変条件だけ身につけても、実務コードの半分に適用できます。そして最後の行の「例外不在」は最も過小評価されたプロパティです。「どんな入力にも未処理の例外がない」というプロパティ1つだけでも、パーサーや入力検証コードのバグを大量に見つけられます。

実践1 — Python Hypothesis

金額計算という実務の定番素材から始めます。カートの合計に割引率を適用してセント単位に丸める関数を検証してみましょう。

# cart.py
from decimal import Decimal, ROUND_HALF_UP


def apply_discount(total_cents: int, discount_percent: int) -> int:
    """合計(セント)に割引率(0-100)を適用してセント単位に丸める。"""
    if not 0 <= discount_percent <= 100:
        raise ValueError("discount_percent must be between 0 and 100")
    if total_cents < 0:
        raise ValueError("total_cents must be non-negative")
    discounted = Decimal(total_cents) * (Decimal(100 - discount_percent) / Decimal(100))
    return int(discounted.quantize(Decimal("1"), rounding=ROUND_HALF_UP))

プロパティテストは次のとおりです。

# test_cart.py
from hypothesis import given, settings, strategies as st
from cart import apply_discount


@given(total=st.integers(min_value=0, max_value=10**12),
       pct=st.integers(min_value=0, max_value=100))
def test_discount_never_negative_and_never_exceeds_total(total, pct):
    result = apply_discount(total, pct)
    # 不変条件1: 割引結果は負になり得ない
    assert result >= 0
    # 不変条件2: 割引結果は元の合計を超えられない
    assert result <= total


@given(total=st.integers(min_value=0, max_value=10**12))
def test_zero_discount_is_identity(total):
    # 事後条件: 0%割引は恒等関数である
    assert apply_discount(total, 0) == total


@given(total=st.integers(min_value=0, max_value=10**12))
def test_full_discount_is_zero(total):
    # 事後条件: 100%割引は常に0である
    assert apply_discount(total, 100) == 0


@given(total=st.integers(min_value=0, max_value=10**12),
       pct=st.integers(min_value=0, max_value=100))
def test_discount_is_monotonic(total, pct):
    # 不変条件3: 割引率が大きいほど結果は小さいか等しい
    if pct < 100:
        assert apply_discount(total, pct + 1) <= apply_discount(total, pct)

もし実装を浮動小数点で書いていたら(Decimalの代わりにfloat)、単調性テストが大きな金額で丸め誤差により壊れる反例をHypothesisが見つけ出します。そしてシュリンキングのおかげで「total=10000000001, pct=33で失敗」のような巨大な反例ではなく、人間がデバッグできる最小の反例として報告されます。

複合構造のジェネレーターも簡単です。

# 有効な注文オブジェクトを生成する戦略
order_strategy = st.builds(
    dict,
    order_id=st.uuids().map(str),
    items=st.lists(
        st.builds(dict,
                  sku=st.text(alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", min_size=8, max_size=8),
                  qty=st.integers(min_value=1, max_value=999),
                  unit_price_cents=st.integers(min_value=1, max_value=10**7)),
        min_size=1, max_size=50,
    ),
)


@given(order=order_strategy)
def test_order_total_equals_sum_of_lines(order):
    total = calculate_order_total(order)
    expected = sum(i["qty"] * i["unit_price_cents"] for i in order["items"])
    assert total == expected

実践2 — Java jqwik

JUnit 5プラットフォーム上で動くjqwikはアノテーションベースで、既存のJavaプロジェクトに自然に溶け込みます。ラウンドトリップパターンでシリアライズを検証する例です。

import net.jqwik.api.*;
import net.jqwik.api.constraints.*;
import static org.assertj.core.api.Assertions.assertThat;

class CsvCodecProperties {

    @Property
    void encodeThenDecodeIsIdentity(@ForAll @StringLength(max = 200) String field) {
        // ラウンドトリップ: どんな文字列もエンコード後にデコードすれば元どおり
        String encoded = CsvCodec.encodeField(field);
        String decoded = CsvCodec.decodeField(encoded);
        assertThat(decoded).isEqualTo(field);
    }

    @Property
    void encodedFieldNeverBreaksRowStructure(
            @ForAll @Size(min = 1, max = 10) List<@StringLength(max = 50) String> fields) {
        // 不変条件: フィールドにカンマ/改行/引用符があっても行構造が維持される
        String row = CsvCodec.encodeRow(fields);
        List<String> parsed = CsvCodec.parseRow(row);
        assertThat(parsed).isEqualTo(fields);
    }

    @Property
    void normalizeIsIdempotent(@ForAll String input) {
        // 冪等性: 正規化を2回しても1回と同じ
        String once = CsvCodec.normalize(input);
        String twice = CsvCodec.normalize(once);
        assertThat(twice).isEqualTo(once);
    }

    @Provide
    Arbitrary<String> japaneseText() {
        // カスタムジェネレーター: ひらがな・カタカナ範囲を含む文字列
        return Arbitraries.strings()
                .withCharRange('あ', 'ん')
                .withCharRange('ア', 'ン')
                .withCharRange('a', 'z')
                .ofMaxLength(100);
    }

    @Property
    void roundTripWithJapanese(@ForAll("japaneseText") String field) {
        assertThat(CsvCodec.decodeField(CsvCodec.encodeField(field))).isEqualTo(field);
    }
}

CSVエンコーダーを自分で書いたことのある方なら想像がつくでしょうが、このラウンドトリッププロパティは引用符の中の引用符、フィールド末尾の改行、空フィールドとnullの区別といった古典的バグをほぼ確実に見つけ出します。例示ベースのテストでこれらの組み合わせをすべて列挙するのは事実上不可能です。

実践3 — JavaScript fast-check

fast-checkはJest/Vitestと自然に組み合わさります。モデル対照パターンで最適化された関数をナイーブな実装と比較する例です。

import fc from "fast-check";
import { describe, it } from "vitest";
import { mergeIntervals } from "../src/intervals";

// 参照実装: 遅いが明らかに正しいバージョン
function mergeIntervalsNaive(intervals: Array<[number, number]>): Array<[number, number]> {
  const points = new Set<number>();
  for (const [s, e] of intervals) {
    for (let i = s; i < e; i++) points.add(i);
  }
  // 連続区間に再グループ化 (小さな範囲でのみ使用)
  const sorted = [...points].sort((a, b) => a - b);
  const out: Array<[number, number]> = [];
  for (const p of sorted) {
    const last = out[out.length - 1];
    if (last && last[1] === p) last[1] = p + 1;
    else out.push([p, p + 1]);
  }
  return out;
}

describe("mergeIntervals", () => {
  it("最適化実装はナイーブ実装と常に同じ結果を出す", () => {
    fc.assert(
      fc.property(
        fc.array(
          fc.tuple(fc.integer({ min: 0, max: 100 }), fc.integer({ min: 0, max: 100 }))
            .map(([a, b]) => (a <= b ? [a, b] : [b, a]) as [number, number]),
          { maxLength: 30 }
        ),
        (intervals) => {
          const fast = mergeIntervals(intervals);
          const slow = mergeIntervalsNaive(intervals);
          return JSON.stringify(fast) === JSON.stringify(slow);
        }
      )
    );
  });

  it("結果の区間は常にソートされており重ならない", () => {
    fc.assert(
      fc.property(
        fc.array(fc.tuple(fc.nat(1000), fc.nat(1000)), { maxLength: 50 }),
        (raw) => {
          const intervals = raw.map(([a, b]) => (a <= b ? [a, b] : [b, a]) as [number, number]);
          const merged = mergeIntervals(intervals);
          for (let i = 1; i < merged.length; i++) {
            // 不変条件: 前の区間の終わり < 次の区間の始まり
            if (merged[i - 1][1] >= merged[i][0]) return false;
          }
          return true;
        }
      )
    );
  });
});

モデル対照パターンの魅力は「正解」を知らなくてもよいことです。遅いが明らかに正しい実装が1つあれば、最適化バージョンがそれと同一に動作するかを数百種類の入力で検証できます。パフォーマンス最適化PRの回帰防止装置として特に強力です。

失敗の再現 — シード固定と反例データベース

ランダムテストの古典的な心配は「昨日は失敗したのに今日は通ったらどうする」です。現代のPBTツールはこの問題を解決済みです。

# Hypothesis: 失敗した反例は .hypothesis/examples ディレクトリに自動保存され、
# 次の実行で最初に再試行される (反例データベース)

# 特定の反例を恒久的な回帰テストとして釘付けにすることもできる
from hypothesis import example

@given(st.text())
@example("")          # 過去に失敗した反例を明示的に固定
@example("\x00")
def test_normalize_roundtrip(s):
    assert denormalize(normalize(s)) == s
// jqwik: 失敗時にシードが出力され、同じシードで再現できる
@Property(seed = "8723648723648")
void reproducesFailure(@ForAll String input) { /* ... */ }
// jqwikも失敗サンプルを .jqwik-database に保存し、次の実行で優先的に再試行する
// fast-check: 失敗レポートにseedとpathが出力される
fc.assert(prop, { seed: 1042, path: "0:0:1" }); // 正確にその反例から再実行

CIで推奨する運用方式はこうです。失敗ログに出たシード/反例をそのままexampleまたはseedとしてコードに焼き込み、回帰テストに昇格させるのです。こうすればランダム性は「再現不可能なflaky」ではなく「恒久的な回帰スイートを自動採掘する装置」になります。

ステートマシンテスト — stateful testing

ここまでは純粋関数をテストしましたが、実務の難しいバグは状態を持つコード(キャッシュ、コネクションプール、カート、DBレイヤー)に棲んでいます。ステートマシンテストは「ランダムな操作シーケンス」を生成し、実際の実装と単純なモデルを並行実行して、毎ステップの一致を検証します。

# LRUキャッシュをdictモデルと対照するステートマシンテスト (Hypothesis)
from hypothesis import strategies as st
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant
from lru import LRUCache

CAPACITY = 8


class LRUCacheMachine(RuleBasedStateMachine):
    def __init__(self):
        super().__init__()
        self.real = LRUCache(capacity=CAPACITY)
        self.model = {}   # モデル: 順序を覚えるdict (Pythonのdictは挿入順を保持)

    @rule(key=st.integers(0, 20), value=st.integers())
    def put(self, key, value):
        self.real.put(key, value)
        self.model.pop(key, None)
        self.model[key] = value
        if len(self.model) > CAPACITY:
            oldest = next(iter(self.model))
            del self.model[oldest]

    @rule(key=st.integers(0, 20))
    def get(self, key):
        expected = self.model.get(key)
        if expected is not None:
            # モデル側でも最近使用として更新
            self.model.pop(key)
            self.model[key] = expected
        assert self.real.get(key) == expected

    @invariant()
    def size_never_exceeds_capacity(self):
        assert self.real.size() <= CAPACITY


TestLRUCache = LRUCacheMachine.TestCase

このテストが失敗すると、Hypothesisは「put(3, 1), put(4, 2), get(3), put(5, 9)で失敗」のような最小操作シーケンスにシュリンキングして報告します。LRU更新の漏れ、容量境界のoff-by-oneといったバグはこの方式の前では生き残りにくいです。fast-checkもcommands APIで、jqwikもaction chainsで同じパターンをサポートしています。

実務適用領域 — どこに最初に使うか

PBTが特に強い領域を優先順位付きで整理します。

領域推奨プロパティパターン期待効果
パーサー/フォーマッターラウンドトリップ、例外不在入力コーナーケースの大量発掘
シリアライズ/デシリアライズラウンドトリップ、スキーマ互換バージョン間の互換性破壊の早期発見
金額/数量計算不変条件、単調性、合計保存丸め/オーバーフローのバグ遮断
正規化/重複排除冪等性二重適用バグの遮断
データ構造の実装モデル対照、ステートマシン境界条件の網羅
並行処理コードステートマシン+ランダムなインターリービング再現困難なレース検出の補助
API入力検証例外不在、事後条件セキュリティ観点の堅牢性向上

逆にPBTが非効率な領域もあります。外部システムとの統合そのもの(実際の決済ゲートウェイの呼び出しなど)、ピクセル単位のUI、「正しさ」の定義が主観的なロジック(推薦ランキングなど)には無理に適用しない方がよいです。

CI統合 — 実行時間とflakyの管理

PBTをCIに入れるときの2つの心配、実行時間とflakyは設定で制御します。

# Hypothesis: プロファイルでローカル/CI/夜間を分離
from hypothesis import settings, HealthCheck

settings.register_profile("dev", max_examples=50)
settings.register_profile("ci", max_examples=200, deadline=None,
                          suppress_health_check=[HealthCheck.too_slow])
settings.register_profile("nightly", max_examples=2000)
# 実行時: HYPOTHESIS_PROFILE=ci pytest

運用原則は次のとおりです。

  1. PRゲートでは例の数をほどほどに(100~200)、夜間ビルドで大きく(数千)回します。バグ発掘は夜間が、回帰防止はPRが担当する分業です。
  2. 時刻依存/ネットワーク依存をジェネレーターから除去します。flakyの主犯はランダム性ではなく、隠れた非決定性(現在時刻、外部呼び出し)です。
  3. 失敗時にシードと反例をログに残すようレポーターを設定し、発見された反例は前述のとおりexampleに昇格させます。
  4. deadline(ケースごとの時間制限)はCIマシンの性能ばらつきによる偽の失敗を生みやすいので、CIではオフにするか余裕を持たせます。

AIコード検証とのシナジー — 2026年の視点

今回の再評価の中核動力は、それ自体を取り上げる価値があります。AIが書いたコードとPBTは構造的に相性が良いのです。

  1. AIコードの失敗様式は「それらしい90% + 微妙に間違った10%」です。デモ用の例は通過するのに境界条件で間違うパターンが典型的で、これはまさにPBTが捕まえるよう設計されたバグのクラスです。
  2. プロパティは仕様であり、仕様は人間が握っているべきです。実装はエージェントに任せても、「ラウンドトリップが成立すべき」「合計が保存されるべき」というプロパティ定義を人間が書けば、レビューの焦点がコード1行1行から仕様の検証へと上がります。
  3. エージェントループの自動採点者になります。エージェントに「このプロパティテストを通過するまで修正せよ」と指示すれば、プロパティテストが人間の代わりに反例を突きつけてループを回します。例示テストより過適合(テストだけに合わせたハードコーディング)がはるかに難しい点が重要です。ランダムな入力にはハードコーディングで対応できないからです。

ただし、プロパティの作成までAIに丸ごと任せるのは注意が必要です。実装と同じ誤解を共有したプロパティは、一緒に間違ったまま通過します(自己一致の罠)。プロパティの出所は要件とドメイン知識であるべきで、そこが人間の貢献する部分です。

導入ガイド — 既存テストに漸進的に

ビッグバン導入は不要です。次の順序をおすすめします。

  1. 1週目: 既存コードからラウンドトリップが成立する関数ペア(シリアライズ、エンコード)を1つ選び、プロパティテストを1個追加します。ツールのインストールとCI連携までこの1個で検証します。
  2. 2週目: 「例外不在」プロパティを公開APIの2~3ヶ所に追加します。意外なクラッシュが見つかれば、チームを説得する材料になります。
  3. 3週目: バグが頻発するモジュール1つに不変条件プロパティを設計します。このときチームと一緒に「このモジュールが守るべき法則は何か?」を議論すること自体が設計レビューの効果を生みます。
  4. それ以降: 新しいバグが報告されるたびに「このバグを捕まえたはずのプロパティは何だったか」を振り返りに追加します。プロパティテストはバグの事後分析から最も速く増えていきます。

落とし穴 — こう使うと失敗する

  • 過度なジェネレーターの複雑さ: 有効な入力を作るためにジェネレーターへビジネスロジックを複製し始めたら危険信号です。ジェネレーターが実装と同じくらい複雑になると、ジェネレーターのバグをテストする羽目になります。このときは生成後のフィルタリングより「単純な入力を生成して公開APIで有効な状態を作る」のが定石です。
  • トートロジーなプロパティ: 実装コードをプロパティにそのまま複製すると(assert f(x) == 実装と同じ数式)、何も検証しません。プロパティは実装と異なる角度(ラウンドトリップ、モデル対照)から来るべきです。
  • filterの乱用: 生成された入力の99%を捨てるフィルターはテストを遅くし、入力分布を歪めます。フィルターの代わりに構成的に生成しましょう(偶数が必要なら整数を生成して2を掛ける)。
  • シュリンキングを無視したカスタム生成: mapベースの変換はシュリンキングが付いてきますが、外部乱数で直接作った値はシュリンキングされず、巨大な反例を受け取ることになります。フレームワークのコンビネーターの中で作るのが原則です。
  • 100%置き換えの幻想: PBTは例示テストの代替品ではなく補完財です。意図を文書化する代表例と、空間を探索するプロパティテストは役割が違います。

チェックリスト

  • ラウンドトリップが成立する関数ペアにラウンドトリッププロパティがあるか
  • 公開APIに「有効な入力で例外不在」プロパティがあるか
  • 金額/数量コードに不変条件(負数禁止、合計保存、単調性)が定義されているか
  • 発見された反例がexample/seedで恒久的な回帰テストに昇格する手順があるか
  • CIプロファイル(PRは速く、夜間は深く)が分離されているか
  • ジェネレーターが実装より単純か (複雑になったら設計を再検討)
  • 状態を持つ中核コンポーネントにステートマシンテストがあるか
  • プロパティ定義の出所が要件/ドメイン知識か (実装のコピーではなく)
  • AIが書いたコードのマージ条件にプロパティテスト通過が含まれるか

おわりに

プロパティベーステストは1999年のQuickCheckに始まる古い技法ですが、2026年の私たちには新しい理由で必要になりました。人間がコードをすべて書いていた時代には「自分が考えつかなかった入力」が問題だったとすれば、AIがコードを書く時代には「誰も深く考えていない実装」が量産されることが問題だからです。例示はその実装がそれらしいことしか確認してくれませんが、プロパティはその実装が法則を守ることを確認してくれます。

大げさに始める必要はありません。あなたのコードベースからエンコード-デコードのペアを1つ見つけて、ラウンドトリッププロパティを1つ追加してみてください。高い確率で、そのテストは最初の1週間であなたの知らなかったバグを1つプレゼントしてくれるはずです。

参考資料