Rustプロパティテスト実戦:Proptestで本番前に5種類の隠れたエッジケースバグを見つける

编程语言

Rustプロパティテスト:なぜユニットテストだけでは不十分なのか

何百ものユニットテストを書いても、エッジケースのバグに悩まされていませんか?i32::MAX + 1のオーバーフロー、空文字列のパースクラッシュ、並行競合状態——これらのバグに共通する特徴は、極端な入力を自発的にテストしないことです。**プロパティベーステスト(Property-Based Testing)**は、大量のランダム入力を自動生成し、コードの「プロパティ」(不変条件)が常に成立することを検証します。2026年、Rustのproptestライブラリは成熟し、戦略定義→自動シュリンク→状態マシンテスト→回帰永続化をサポートしています。

本記事では5つのコアパターンから、戦略設計→プロパティ検証→状態テスト→回帰永続化→ファズテスト統合のフルパイプライン実戦を解説します。


コア概念

概念 説明
Property コードが常に満たすべき不変条件/プロパティ
Strategy ランダムなテスト入力を生成する戦略
Shrinking テスト失敗時に最小の失敗ケースに自動縮小
Stateful Testing 状態マシンに基づくプロパティテスト
Regression 失敗ケースを永続化し、回帰を防止
Arbitraries 定義済みのランダムジェネレーター
PropTest Rustプロパティテストフレームワーク
Fuzz Testing ファズテスト、プロパティテストと補完関係

問題分析:プロパティテストが解決する5種類のバグ

  1. 整数オーバーフロー:i32::MAX + 1、usize::MAX等の境界値
  2. 文字列パース:空文字、超長文字、不正UTF-8、特殊文字
  3. 並行競合:マルチスレッド下のデータ競合とデッドロック
  4. 状態マシン違反:状態遷移が期待に合わない
  5. API契約違反:関数の戻り値がドキュメントの約束に合わない

ステップバイステップ:5つのRustプロパティテストコアパターン

パターン1:Proptestの基礎と戦略定義

# Cargo.toml
[dependencies]
proptest = "1.5"
// src/parser.rs
pub fn parse_coordinate(input: &str) -> Result<(f64, f64), ParseError> {
    let parts: Vec<&str> = input.split(',').collect();
    if parts.len() != 2 {
        return Err(ParseError::InvalidFormat);
    }
    let lat: f64 = parts[0].trim().parse().map_err(|_| ParseError::InvalidLatitude)?;
    let lng: f64 = parts[1].trim().parse().map_err(|_| ParseError::InvalidLongitude)?;
    if !(-90.0..=90.0).contains(&lat) {
        return Err(ParseError::InvalidLatitude);
    }
    if !(-180.0..=180.0).contains(&lng) {
        return Err(ParseError::InvalidLongitude);
    }
    Ok((lat, lng))
}

#[derive(Debug, PartialEq)]
pub enum ParseError {
    InvalidFormat,
    InvalidLatitude,
    InvalidLongitude,
}
// tests/parser_proptest.rs
use proptest::prelude::*;
use myapp::parser::{parse_coordinate, ParseError};

proptest! {
    #[test]
    fn test_valid_coordinates(lat in -90.0f64..90.0, lng in -180.0f64..180.0) {
        let input = format!("{},{}", lat, lng);
        let result = parse_coordinate(&input);
        prop_assert!(result.is_ok());
        let (parsed_lat, parsed_lng) = result.unwrap();
        prop_assert!((parsed_lat - lat).abs() < 1e-10);
        prop_assert!((parsed_lng - lng).abs() < 1e-10);
    }

    #[test]
    fn test_invalid_latitude(lat in 90.1f64..1000.0) {
        let input = format!("{},0.0", lat);
        let result = parse_coordinate(&input);
        prop_assert!(matches!(result, Err(ParseError::InvalidLatitude)));
    }

    #[test]
    fn test_roundtrip_any_string(input in ".*") {
        let _ = parse_coordinate(&input);
    }
}

パターン2:カスタム戦略とコンビネータ

// tests/custom_strategy.rs
use proptest::prelude::*;

#[derive(Debug, Clone)]
struct User {
    id: u64,
    name: String,
    email: String,
    age: u8,
}

fn user_strategy() -> impl Strategy<Value = User> {
    (
        any::<u64>(),
        "[a-zA-Z]{3,20}",
        "[a-z]{3,10}@[a-z]{3,10}\\.(com|org|io)",
        1u8..120,
    )
        .prop_map(|(id, name, email, age)| User { id, name, email, age })
}

proptest! {
    #[test]
    fn test_user_validation(user in user_strategy()) {
        prop_assert!(user.age > 0 && user.age <= 120);
        prop_assert!(user.name.len() >= 3);
        prop_assert!(user.email.contains('@'));
    }
}

fn vec_strategy() -> impl Strategy<Value = Vec<i32>> {
    prop::collection::vec(any::<i32>(), 0..100)
}

proptest! {
    #[test]
    fn test_sort_preserves_elements(mut input in vec_strategy()) {
        let original = input.clone();
        input.sort();
        prop_assert_eq!(input.len(), original.len());
        for elem in &original {
            prop_assert!(input.contains(elem));
        }
        for window in input.windows(2) {
            prop_assert!(window[0] <= window[1]);
        }
    }
}

パターン3:状態マシンプロパティテスト

// tests/state_machine.rs
use proptest::prelude::*;
use std::collections::VecDeque;

#[derive(Debug, Clone)]
enum QueueAction {
    Enqueue(i32),
    Dequeue,
    Peek,
    Len,
}

fn queue_action_strategy() -> impl Strategy<Value = QueueAction> {
    prop_oneof![
        any::<i32>().prop_map(QueueAction::Enqueue),
        Just(QueueAction::Dequeue),
        Just(QueueAction::Peek),
        Just(QueueAction::Len),
    ]
}

proptest! {
    #[test]
    fn test_queue_state_machine(actions in prop::collection::vec(queue_action_strategy(), 1..100)) {
        let mut queue: VecDeque<i32> = VecDeque::new();
        let mut expected_len = 0usize;

        for action in actions {
            match action {
                QueueAction::Enqueue(val) => {
                    queue.push_back(val);
                    expected_len += 1;
                }
                QueueAction::Dequeue => {
                    let result = queue.pop_front();
                    if expected_len > 0 {
                        prop_assert!(result.is_some());
                        expected_len -= 1;
                    } else {
                        prop_assert!(result.is_none());
                    }
                }
                QueueAction::Peek => {
                    let result = queue.front();
                    if expected_len > 0 {
                        prop_assert!(result.is_some());
                    } else {
                        prop_assert!(result.is_none());
                    }
                }
                QueueAction::Len => {
                    prop_assert_eq!(queue.len(), expected_len);
                }
            }
        }
    }
}

パターン4:回帰永続化と失敗ケースの再現

// tests/regression.rs
use proptest::prelude::*;

proptest! {
    #![proptest_config(ProptestConfig {
        failure_persistence: Some(Box::new(FileFailurePersistence::WithSource("regressions"))),
        .. ProptestConfig::default()
    })]

    #[test]
    fn test_base64_roundtrip(input in prop::collection::vec(any::<u8>(), 0..1000)) {
        let encoded = base64_encode(&input);
        let decoded = base64_decode(&encoded).unwrap();
        prop_assert_eq!(input, decoded);
    }
}

パターン5:cargo-fuzzファズテストとの統合

// fuzz/fuzz_targets/parse_coordinate.rs
#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
    if let Ok(s) = std::str::from_utf8(data) {
        let _ = myapp::parser::parse_coordinate(s);
    }
});
# fuzz/Cargo.toml
[package]
name = "myapp-fuzz"
version = "0.0.0"
publish = false

[dependencies]
libfuzzer-sys = "0.4"
myapp = { path = ".." }

[[bin]]
name = "parse_coordinate"
path = "fuzz_targets/parse_coordinate.rs"

よくある落とし穴

落とし穴1:戦略スコープが狭すぎる

// ❌ 間違い:正常範囲のみテスト
let lat in -90.0f64..90.0

// ✅ 正しい:境界と異常値も同時にテスト
proptest! {
    #[test]
    fn test_normal(lat in -90.0f64..90.0) { /* ... */ }

    #[test]
    fn test_boundary(lat in 89.9f64..90.1) { /* ... */ }

    #[test]
    fn test_extreme(lat in -1e10f64..1e10) { /* ... */ }
}

落とし穴2:Shrinkingを無視する

// ❌ 間違い:手動で入力を生成、シュリンク不可
let input = rand::random::<i32>();

// ✅ 正しい:proptest戦略を使用、自動シュリンク
let input in any::<i32>()

落とし穴3:プロパティが緩すぎる

// ❌ 間違い:プロパティが弱すぎ、ほぼどんな実装も通過
prop_assert!(result.is_ok() || result.is_err());

// ✅ 正しい:プロパティが十分に強く、正しい実装のみ通過
prop_assert_eq!(result.unwrap(), expected);

落とし穴4:状態テストに不変条件がない

// ❌ 間違い:操作を実行するだけで不変条件を検証しない
for action in actions {
    queue.execute(action);
}

// ✅ 正しい:各ステップで不変条件を検証
for action in actions {
    queue.execute(action);
    prop_assert!(queue.len() <= max_capacity);
    prop_assert!(queue.is_consistent());
}

落とし穴5:回帰ケースを永続化しない

// ❌ 間違い:デフォルト設定を使用、失敗ケースが永続化されない
proptest! {
    #[test]
    fn test_something(input in any::<i32>()) { /* ... */ }
}

// ✅ 正しい:failure_persistenceを設定
proptest! {
    #![proptest_config(ProptestConfig {
        failure_persistence: Some(Box::new(FileFailurePersistence::WithSource("regressions"))),
        .. ProptestConfig::default()
    })]
    #[test]
    fn test_something(input in any::<i32>()) { /* ... */ }
}

エラートラブルシューティング

# エラーメッセージ 原因 解決方法
1 test failed: minimal failing input プロパティが成立しない シュリンクされた入力とプロパティロジックを確認
2 too many retries 戦略フィルターが厳しすぎる フィルターを緩めるかprop_filterを使用
3 stack overflow 再帰戦略が無限展開 prop_recursiveで深さを制限
4 timeout テストケースの実行が遅すぎる ケース数を減らすかテストコードを最適化
5 cannot find regression file 回帰ファイルのパスが間違っている failure_persistence設定を確認
6 strategy exhausted 戦略空間が枯渇 戦略範囲を拡大するかケース数を削減
7 assertion failed after shrink シュリンク後も失敗 バグを修正、戦略の調整だけでは不十分
8 unwinding panic コードがパニック テストでcatch_unwindを使用
9 duplicate test name テスト名が重複 各proptest!ブロックで一意な名前を使用
10 type mismatch in strategy 戦略の型が不一致 prop_mapの型変換を確認

高度な最適化

  1. カスタムShrinkingArbitraryトレイトを実装してカスタム縮小ロジックを定義
  2. 並列テストproptest::test_runner::Configmax_shrink_itersで並列度を制御
  3. CI統合:CIでcargo testを実行し、回帰ファイルをコミット
  4. カバレッジガイド:cargo-tarpaulinと組み合わせてプロパティテストのカバレッジを分析
  5. QuickCheckとの比較:proptestとQuickCheckを同時に実行し、補完的にバグを発見

比較分析

次元 proptest QuickCheck cargo-fuzz afl
自動Shrinking ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐
状態テスト ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐ ⭐⭐
回帰永続化 ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐
戦略合成 ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐ ⭐⭐
パフォーマンス ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐
カバレッジガイド ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐

まとめ:プロパティテストは「手動でテストケースを構築する」から「自動的にエッジケースバグを発見する」へと進化させます。戦略設計→プロパティ検証→状態マシンテスト→回帰永続化→ファズテストの五位一体、proptestは2026年のRustプロパティテストの最適な選択です。コア原則:プロパティは不変条件、戦略は入力空間、Shrinkingはバグの特定


オンラインツールおすすめ

ブラウザローカルツールを無料で試す →

#Rust测试#属性测试#proptest#Rust模糊测试#2026#编程语言