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種類のバグ
- 整数オーバーフロー:i32::MAX + 1、usize::MAX等の境界値
- 文字列パース:空文字、超長文字、不正UTF-8、特殊文字
- 並行競合:マルチスレッド下のデータ競合とデッドロック
- 状態マシン違反:状態遷移が期待に合わない
- 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の型変換を確認 |
高度な最適化
- カスタムShrinking:
Arbitraryトレイトを実装してカスタム縮小ロジックを定義 - 並列テスト:
proptest::test_runner::Configのmax_shrink_itersで並列度を制御 - CI統合:CIで
cargo testを実行し、回帰ファイルをコミット - カバレッジガイド:cargo-tarpaulinと組み合わせてプロパティテストのカバレッジを分析
- QuickCheckとの比較:proptestとQuickCheckを同時に実行し、補完的にバグを発見
比較分析
| 次元 | proptest | QuickCheck | cargo-fuzz | afl |
|---|---|---|---|---|
| 自動Shrinking | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
| 状態テスト | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
| 回帰永続化 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| 戦略合成 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ |
| パフォーマンス | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
| カバレッジガイド | ⭐⭐ | ⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
まとめ:プロパティテストは「手動でテストケースを構築する」から「自動的にエッジケースバグを発見する」へと進化させます。戦略設計→プロパティ検証→状態マシンテスト→回帰永続化→ファズテストの五位一体、proptestは2026年のRustプロパティテストの最適な選択です。コア原則:プロパティは不変条件、戦略は入力空間、Shrinkingはバグの特定。
オンラインツールおすすめ
- JSONフォーマッター:/ja/json/format
- Hash計算:/ja/encode/hash
- cURL→コード変換:/ja/dev/curl-to-code
ブラウザローカルツールを無料で試す →
#Rust测试#属性测试#proptest#Rust模糊测试#2026#编程语言