rstest

Rustテストフレームワークフィクスチャパラメータ化テスト単体テスト

rstest

概要

rstestは、Rust用のフィクスチャベーステストフレームワークです。標準の#[test]属性を拡張し、フィクスチャ(テストデータの準備)、パラメータ化テスト、非同期テスト、タイムアウト機能などの高度なテスト機能を提供します。テストコードの重複を削減し、保守性と可読性を大幅に向上させる現代的なRustテストソリューションです。

詳細

主な特徴

  • フィクスチャシステム: #[fixture]によるテストデータの再利用可能な準備
  • パラメータ化テスト: #[case]#[values]による複数ケースの効率的テスト
  • 非同期テストサポート: async関数のネイティブサポート
  • タイムアウト機能: #[timeout]によるテスト実行時間制御
  • テンプレート機能: rstest_reuseによる複雑なテストパターンの再利用
  • カスタムアサーション: 柔軟なアサーション拡張機能
  • 並列実行: 高速なテスト実行のための並列化
  • トレース機能: デバッグ用の詳細なテスト情報出力

アーキテクチャ

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

  • フィクスチャエンジン: テストデータの生成と依存関係管理
  • パラメータジェネレーター: テストケースの自動生成機能
  • 実行ランナー: 並列実行とタイムアウト制御
  • コンテキスト管理: テストメタデータとライフサイクル管理

メリット・デメリット

メリット

  • 高い再利用性: フィクスチャによるテストデータの効率的な共有
  • 豊富な表現力: パラメータ化テストによる包括的なテストカバレッジ
  • 開発効率向上: テンプレート機能による重複コード削減
  • 強力なデバッグ: トレース機能による詳細な実行情報
  • 非同期対応: 最新のRust非同期プログラミングとの完全統合
  • 標準互換性: Rustの標準テストシステムとの seamless統合

デメリット

  • 学習コスト: 豊富な機能による初期学習コストの増加
  • コンパイル時間: マクロの多用によるわずかなコンパイル時間増加
  • 外部依存: 標準ライブラリ以外のクレート依存
  • 複雑性: シンプルなテストには過剰な機能

参考ページ

書き方の例

基本的なフィクスチャ

use rstest::*;

#[fixture]
fn sample_data() -> Vec<i32> {
    vec![1, 2, 3, 4, 5]
}

#[rstest]
fn test_data_length(sample_data: Vec<i32>) {
    assert_eq!(sample_data.len(), 5);
}

#[rstest]
fn test_data_sum(sample_data: Vec<i32>) {
    let sum: i32 = sample_data.iter().sum();
    assert_eq!(sum, 15);
}

パラメータ化テスト

use rstest::rstest;

#[rstest]
#[case(0, 0)]
#[case(1, 1)]
#[case(2, 1)]
#[case(3, 2)]
#[case(4, 3)]
#[case(5, 5)]
fn test_fibonacci(#[case] input: u32, #[case] expected: u32) {
    assert_eq!(fibonacci(input), expected);
}

// 名前付きケース
#[rstest]
#[case::zero(0, 0)]
#[case::one(1, 1)]
#[case::small_numbers(2, 1)]
#[case::larger_numbers(10, 55)]
fn test_fibonacci_named(#[case] n: u32, #[case] expected: u32) {
    assert_eq!(fibonacci(n), expected);
}

バリューリストテスト

#[rstest]
fn test_is_even(
    #[values(0, 2, 4, 6, 8, 10)] n: i32
) {
    assert!(n % 2 == 0);
}

#[rstest]
fn test_string_validation(
    #[values("hello", "world", "rust")] input: &str
) {
    assert!(!input.is_empty());
    assert!(input.chars().all(|c| c.is_ascii_lowercase()));
}

// 複数のバリューリスト(組み合わせテスト)
#[rstest]
fn test_arithmetic(
    #[values(1, 2)] a: i32,
    #[values(3, 4)] b: i32
) {
    assert!(a + b > 0);
    // (1,3), (1,4), (2,3), (2,4) の4パターンでテストされる
}

高度なフィクスチャ

#[fixture]
fn database() -> TestDatabase {
    TestDatabase::new()
}

#[fixture]
fn user(#[default("john")] name: &str, #[default(25)] age: u32) -> User {
    User::new(name, age)
}

#[rstest]
fn test_default_user(user: User) {
    assert_eq!(user.name(), "john");
    assert_eq!(user.age(), 25);
}

#[rstest]
fn test_custom_user(#[with("alice", 30)] user: User) {
    assert_eq!(user.name(), "alice");
    assert_eq!(user.age(), 30);
}

#[rstest]
fn test_partial_custom_user(#[with("bob")] user: User) {
    assert_eq!(user.name(), "bob");
    assert_eq!(user.age(), 25); // デフォルト値
}

非同期テスト

#[fixture]
async fn async_data() -> Result<String, Error> {
    fetch_data_from_api().await
}

#[rstest]
async fn test_async_operation(#[future] async_data: String) {
    assert!(!async_data.is_empty());
}

#[rstest]
#[case(1, 2, 3)]
#[case(5, 5, 10)]
async fn test_async_calculation(
    #[case] a: i32, 
    #[case] b: i32, 
    #[case] expected: i32
) {
    let result = async_add(a, b).await;
    assert_eq!(result, expected);
}

タイムアウトとエラーハンドリング

use std::time::Duration;

#[rstest]
#[timeout(Duration::from_millis(100))]
async fn test_fast_operation() {
    // 100ms以内に完了する必要がある
    quick_async_operation().await;
}

#[rstest]
#[case::should_pass(Duration::from_millis(10), 4)]
#[timeout(Duration::from_millis(50))]
#[case::should_timeout(Duration::from_millis(100), 4)]
#[case::should_fail_value(Duration::from_millis(10), 5)]
#[timeout(Duration::from_millis(200))]
async fn test_with_timeout_override(
    #[case] delay: Duration, 
    #[case] expected: u32
) {
    let result = delayed_calculation(2, 2, delay).await;
    assert_eq!(result, expected);
}

#[rstest]
#[should_panic(expected = "Division by zero")]
fn test_panic_handling() {
    divide(10, 0);
}

テンプレートとコード再利用

use rstest_reuse::{self, *};

// テンプレートの定義
#[template]
#[rstest]
#[case(2, 2)]
#[case(4, 4)]
#[case(8, 8)]
fn power_of_two_cases(#[case] input: u32, #[case] expected: u32) {}

// テンプレートの適用
#[apply(power_of_two_cases)]
fn test_is_power_of_two(input: u32, expected: u32) {
    assert_eq!(input, expected);
    assert!(is_power_of_two(input));
}

#[apply(power_of_two_cases)]
fn test_log_base_two(input: u32, expected: u32) {
    assert_eq!(log_base_two(input), expected.trailing_zeros());
}

コンテキストとトレース

use rstest::{rstest, Context};

#[rstest]
#[case::quick_test(42)]
#[case::another_test(100)]
#[trace] // デバッグ情報を有効化
fn test_with_context(#[context] ctx: Context, #[case] value: i32) {
    println!("Test: {}", ctx.name);
    println!("Description: {:?}", ctx.description);
    println!("Case: {:?}", ctx.case);
    
    // テスト実行時間の計測
    let start = ctx.start;
    std::thread::sleep(std::time::Duration::from_millis(10));
    let elapsed = start.elapsed();
    
    assert!(elapsed >= std::time::Duration::from_millis(10));
    assert!(value > 0);
}

複雑なフィクスチャ依存関係

#[fixture]
fn database_config() -> DatabaseConfig {
    DatabaseConfig::test_config()
}

#[fixture]
fn database(database_config: DatabaseConfig) -> Database {
    Database::connect(database_config)
}

#[fixture]
fn user_repository(database: Database) -> UserRepository {
    UserRepository::new(database)
}

#[fixture]
fn test_user() -> User {
    User::new("test_user", "[email protected]")
}

#[rstest]
fn test_user_crud_operations(
    user_repository: UserRepository,
    test_user: User
) {
    // ユーザー作成
    let created_user = user_repository.create(test_user.clone()).unwrap();
    assert_eq!(created_user.name(), test_user.name());
    
    // ユーザー取得
    let found_user = user_repository.find_by_id(created_user.id()).unwrap();
    assert_eq!(found_user.name(), test_user.name());
    
    // ユーザー更新
    let updated_user = user_repository.update_email(
        created_user.id(), 
        "[email protected]"
    ).unwrap();
    assert_eq!(updated_user.email(), "[email protected]");
    
    // ユーザー削除
    user_repository.delete(created_user.id()).unwrap();
    assert!(user_repository.find_by_id(created_user.id()).is_err());
}

ファイルベーステスト

#[rstest]
fn test_config_files(
    #[files("tests/configs/*.toml")] path: std::path::PathBuf
) {
    let config_content = std::fs::read_to_string(&path).unwrap();
    let config: Config = toml::from_str(&config_content).unwrap();
    
    // 設定ファイルの妥当性チェック
    assert!(!config.database_url.is_empty());
    assert!(config.port > 0);
    assert!(config.port < 65536);
}