PropTest

RustプロパティベーステストProperty Testing自動テスト生成縮小化

PropTest

概要

PropTestは、Rust用のプロパティベーステストフレームワークです。QuickCheckファミリーの一員として、Python用のHypothesisフレームワークからインスパイアされています。任意の入力に対してコードの特定のプロパティが成り立つことをテストし、失敗が見つかった場合は自動的に問題を再現する最小限のテストケースを見つけます。戦略ベースの生成システムと高度な縮小化機能により、従来の単体テストでは発見困難なエッジケースを効率的に検出します。

詳細

主な特徴

  • 戦略ベース生成: 明示的なStrategyオブジェクトによる柔軟なテストデータ生成
  • 自動縮小化: 失敗したテストケースを最小限の形に自動的に削減
  • 制約対応生成: 制約を認識し、無効な値の生成を回避する賢い生成機能
  • 豊富な組み込み戦略: プリミティブ型からコレクションまでの包括的な戦略
  • カスタム戦略: 独自データ型のための柔軟な戦略作成機能
  • ステートフルテスト: 複雑な状態遷移のテストサポート
  • 回帰テスト: 一度見つかった失敗ケースの永続化

アーキテクチャ

PropTestは以下の核心コンポーネントで構成されています:

  • Strategy: ランダム値生成と縮小化の定義
  • Test Runner: プロパティテストの実行とレポート
  • Shrinking Engine: 失敗ケースの最小化エンジン
  • Value Tree: 生成値と縮小化の関係管理

戦略システム

戦略は二つの重要な機能を定義します:

  1. 乱数ジェネレーターから特定の型のランダム値を生成する方法
  2. 値をより「シンプル」な形に「縮小化」する方法

メリット・デメリット

メリット

  • 包括的テスト: 大量のランダム入力による徹底的な検証
  • エッジケース発見: 人間が思いつかない境界条件の自動発見
  • 効率的デバッグ: 自動縮小化による最小限の失敗例の提供
  • 高い表現力: 複雑なプロパティを自然に記述可能
  • 回帰防止: 発見された問題の自動的な回帰テスト化
  • 制約対応: 複雑な制約条件を満たすテストデータの生成

デメリット

  • パフォーマンスコスト: QuickCheckに比べて最大1桁遅い生成
  • 学習コスト: プロパティベーステストの概念習得が必要
  • 非決定性: ランダム生成による再現性の課題
  • 複雑性: 状態管理が必要な高度な縮小化モデル

参考ページ

書き方の例

基本的なプロパティテスト

use proptest::prelude::*;

// 加算の交換法則をテスト
proptest! {
    #[test]
    fn test_addition_commutative(a: i32, b: i32) {
        prop_assert_eq!(a + b, b + a);
    }
}

// 乗算の結合法則をテスト
proptest! {
    #[test]
    fn test_multiplication_associative(a: i32, b: i32, c: i32) {
        prop_assert_eq!((a * b) * c, a * (b * c));
    }
}

// 文字列操作のプロパティ
proptest! {
    #[test]
    fn test_string_reverse_involution(s: String) {
        let reversed_twice: String = s.chars().rev().collect::<String>()
            .chars().rev().collect();
        prop_assert_eq!(s, reversed_twice);
    }
}

カスタム戦略

use proptest::prelude::*;

// カスタム構造体
#[derive(Debug, Clone, PartialEq)]
struct Person {
    name: String,
    age: u8,
    email: String,
}

// Person用の戦略
fn person_strategy() -> impl Strategy<Value = Person> {
    (
        "[a-zA-Z]{1,20}",  // 名前:1-20文字のアルファベット
        0u8..150u8,        // 年齢:0-149歳
        r"[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}" // メールアドレス
    ).prop_map(|(name, age, email)| Person {
        name,
        age,
        email,
    })
}

proptest! {
    #[test]
    fn test_person_serialization(person in person_strategy()) {
        let json = serde_json::to_string(&person).unwrap();
        let deserialized: Person = serde_json::from_str(&json).unwrap();
        prop_assert_eq!(person, deserialized);
    }
}

コレクション戦略

use proptest::collection::{vec, hash_set};

proptest! {
    #[test]
    fn test_vec_push_length(
        mut v in vec(any::<i32>(), 0..100),
        item: i32
    ) {
        let original_len = v.len();
        v.push(item);
        prop_assert_eq!(v.len(), original_len + 1);
    }
    
    #[test]
    fn test_set_insert_idempotent(
        mut set in hash_set(any::<i32>(), 0..100),
        item: i32
    ) {
        set.insert(item.clone());
        let size_after_first = set.len();
        
        set.insert(item);
        let size_after_second = set.len();
        
        prop_assert_eq!(size_after_first, size_after_second);
    }
}

制約付き生成

// 正の整数のみ生成
proptest! {
    #[test]
    fn test_positive_square_root(n in 1u32..1000000u32) {
        let sqrt = (n as f64).sqrt() as u32;
        prop_assert!(sqrt * sqrt <= n);
        prop_assert!((sqrt + 1) * (sqrt + 1) > n);
    }
}

// 特定の条件を満たす文字列
proptest! {
    #[test]
    fn test_valid_identifier(
        name in r"[a-zA-Z_][a-zA-Z0-9_]*"
    ) {
        prop_assert!(is_valid_identifier(&name));
        prop_assert!(!name.is_empty());
        prop_assert!(name.chars().next().unwrap().is_alphabetic() || 
                    name.chars().next().unwrap() == '_');
    }
}

// 範囲制約
proptest! {
    #[test]
    fn test_percentage_calculation(
        total in 1u32..1000000u32,
        part in any::<u32>().prop_filter("part <= total", |&part| part <= total)
    ) {
        let percentage = (part as f64 / total as f64) * 100.0;
        prop_assert!(percentage >= 0.0);
        prop_assert!(percentage <= 100.0);
    }
}

ステートフルテスト

use proptest::test_runner::{TestCaseResult, TestRunner};
use proptest::state_machine::{StateMachineTest, ReferenceStateMachine};

#[derive(Debug, Clone)]
struct Stack<T> {
    items: Vec<T>,
}

impl<T> Stack<T> {
    fn new() -> Self {
        Stack { items: Vec::new() }
    }
    
    fn push(&mut self, item: T) {
        self.items.push(item);
    }
    
    fn pop(&mut self) -> Option<T> {
        self.items.pop()
    }
    
    fn is_empty(&self) -> bool {
        self.items.is_empty()
    }
    
    fn len(&self) -> usize {
        self.items.len()
    }
}

// スタックの状態遷移テスト
#[derive(Debug, Clone)]
enum StackCommand {
    Push(i32),
    Pop,
}

proptest! {
    #[test]
    fn test_stack_operations(
        commands in prop::collection::vec(
            prop_oneof![
                any::<i32>().prop_map(StackCommand::Push),
                Just(StackCommand::Pop),
            ],
            0..100
        )
    ) {
        let mut stack = Stack::new();
        let mut reference_vec = Vec::new();
        
        for command in commands {
            match command {
                StackCommand::Push(value) => {
                    stack.push(value);
                    reference_vec.push(value);
                    prop_assert_eq!(stack.len(), reference_vec.len());
                }
                StackCommand::Pop => {
                    let stack_result = stack.pop();
                    let reference_result = reference_vec.pop();
                    prop_assert_eq!(stack_result, reference_result);
                    prop_assert_eq!(stack.len(), reference_vec.len());
                }
            }
        }
    }
}

高度な戦略組み合わせ

// 依存関係のある値の生成
fn ordered_pair() -> impl Strategy<Value = (i32, i32)> {
    any::<i32>().prop_flat_map(|first| {
        (Just(first), first..i32::MAX)
    })
}

// 条件付き戦略
fn conditional_strategy() -> impl Strategy<Value = String> {
    any::<bool>().prop_flat_map(|use_numbers| {
        if use_numbers {
            r"[0-9]{1,10}".boxed()
        } else {
            r"[a-zA-Z]{1,10}".boxed()
        }
    })
}

// 複雑なネストした構造
#[derive(Debug, Clone, PartialEq)]
struct Config {
    database_url: String,
    port: u16,
    features: Vec<String>,
    cache_settings: Option<CacheConfig>,
}

#[derive(Debug, Clone, PartialEq)]
struct CacheConfig {
    max_size: usize,
    ttl_seconds: u64,
}

fn cache_config_strategy() -> impl Strategy<Value = CacheConfig> {
    (1usize..1000000, 1u64..86400).prop_map(|(max_size, ttl_seconds)| {
        CacheConfig { max_size, ttl_seconds }
    })
}

fn config_strategy() -> impl Strategy<Value = Config> {
    (
        r"postgresql://[a-zA-Z0-9]+:[a-zA-Z0-9]+@[a-zA-Z0-9.-]+:[0-9]+/[a-zA-Z0-9_]+",
        1024u16..65535u16,
        prop::collection::vec(r"[a-zA-Z_][a-zA-Z0-9_]*", 0..10),
        prop::option::of(cache_config_strategy()),
    ).prop_map(|(database_url, port, features, cache_settings)| {
        Config { database_url, port, features, cache_settings }
    })
}

proptest! {
    #[test]
    fn test_config_validation(config in config_strategy()) {
        prop_assert!(config.port >= 1024);
        prop_assert!(!config.database_url.is_empty());
        
        if let Some(cache) = &config.cache_settings {
            prop_assert!(cache.max_size > 0);
            prop_assert!(cache.ttl_seconds > 0);
        }
    }
}

エラーハンドリングと回帰テスト

use proptest::test_runner::TestCaseError;

// カスタムアサーション
fn prop_assert_nearly_equal(a: f64, b: f64, epsilon: f64) -> Result<(), TestCaseError> {
    if (a - b).abs() <= epsilon {
        Ok(())
    } else {
        Err(TestCaseError::Fail(format!(
            "assertion failed: `{} ≈ {}` (epsilon: {})", a, b, epsilon
        ).into()))
    }
}

proptest! {
    #[test]
    fn test_floating_point_arithmetic(
        a in -1000.0..1000.0f64,
        b in -1000.0..1000.0f64
    ) {
        // 除算のテスト(ゼロ除算を回避)
        if b.abs() > 1e-10 {
            let result = a / b;
            prop_assert_nearly_equal(result * b, a, 1e-10)?;
        }
    }
}

// 回帰テストの設定
proptest! {
    #![proptest_config(ProptestConfig {
        cases: 1000,  // テストケース数
        max_shrink_iters: 10000,  // 最大縮小化反復数
        failure_persistence: Some(Box::new(
            proptest::test_runner::FileFailurePersistence::
                WithSource("regressions")
        )),
        .. ProptestConfig::default()
    })]
    
    #[test]
    fn test_complex_algorithm(input in complex_input_strategy()) {
        let result = complex_algorithm(&input);
        prop_assert!(validate_result(&result));
    }
}