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