rstest

RustTesting FrameworkFixturesParameterized TestingUnit Testing

rstest

Overview

rstest is a fixture-based testing framework for Rust. It extends the standard #[test] attribute to provide advanced testing features including fixtures (test data preparation), parameterized tests, async tests, and timeout functionality. It significantly improves maintainability and readability by reducing test code duplication, making it a modern Rust testing solution.

Details

Key Features

  • Fixture System: Reusable test data preparation using #[fixture]
  • Parameterized Testing: Efficient testing of multiple cases using #[case] and #[values]
  • Async Test Support: Native support for async functions
  • Timeout Functionality: Test execution time control using #[timeout]
  • Template Features: Complex test pattern reuse with rstest_reuse
  • Custom Assertions: Flexible assertion extension capabilities
  • Parallel Execution: Parallelization for fast test execution
  • Trace Functionality: Detailed test information output for debugging

Architecture

rstest consists of the following core components:

  • Fixture Engine: Test data generation and dependency management
  • Parameter Generator: Automatic test case generation functionality
  • Execution Runner: Parallel execution and timeout control
  • Context Management: Test metadata and lifecycle management

Pros and Cons

Pros

  • High Reusability: Efficient sharing of test data through fixtures
  • Rich Expressiveness: Comprehensive test coverage with parameterized tests
  • Improved Development Efficiency: Reduced duplicate code through template features
  • Powerful Debugging: Detailed execution information via trace functionality
  • Async Support: Complete integration with modern Rust async programming
  • Standard Compatibility: Seamless integration with Rust's standard test system

Cons

  • Learning Curve: Increased initial learning cost due to rich features
  • Compile Time: Slight increase in compile time due to heavy macro usage
  • External Dependencies: Dependency on crates beyond the standard library
  • Complexity: Excessive features for simple tests

References

Code Examples

Basic Fixtures

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);
}

Parameterized Testing

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);
}

// Named cases
#[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);
}

Value List Testing

#[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()));
}

// Multiple value lists (combinatorial testing)
#[rstest]
fn test_arithmetic(
    #[values(1, 2)] a: i32,
    #[values(3, 4)] b: i32
) {
    assert!(a + b > 0);
    // Tests with patterns: (1,3), (1,4), (2,3), (2,4)
}

Advanced Fixtures

#[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); // Default value
}

Async Testing

#[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);
}

Timeout and Error Handling

use std::time::Duration;

#[rstest]
#[timeout(Duration::from_millis(100))]
async fn test_fast_operation() {
    // Must complete within 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);
}

Templates and Code Reuse

use rstest_reuse::{self, *};

// Template definition
#[template]
#[rstest]
#[case(2, 2)]
#[case(4, 4)]
#[case(8, 8)]
fn power_of_two_cases(#[case] input: u32, #[case] expected: u32) {}

// Template application
#[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());
}

Context and Tracing

use rstest::{rstest, Context};

#[rstest]
#[case::quick_test(42)]
#[case::another_test(100)]
#[trace] // Enable debug information
fn test_with_context(#[context] ctx: Context, #[case] value: i32) {
    println!("Test: {}", ctx.name);
    println!("Description: {:?}", ctx.description);
    println!("Case: {:?}", ctx.case);
    
    // Measure test execution time
    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);
}

Complex Fixture Dependencies

#[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
) {
    // Create user
    let created_user = user_repository.create(test_user.clone()).unwrap();
    assert_eq!(created_user.name(), test_user.name());
    
    // Find user
    let found_user = user_repository.find_by_id(created_user.id()).unwrap();
    assert_eq!(found_user.name(), test_user.name());
    
    // Update user
    let updated_user = user_repository.update_email(
        created_user.id(), 
        "[email protected]"
    ).unwrap();
    assert_eq!(updated_user.email(), "[email protected]");
    
    // Delete user
    user_repository.delete(created_user.id()).unwrap();
    assert!(user_repository.find_by_id(created_user.id()).is_err());
}

File-Based Testing

#[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();
    
    // Validate configuration file
    assert!(!config.database_url.is_empty());
    assert!(config.port > 0);
    assert!(config.port < 65536);
}