How do I write composable predicates for testing in Rust?

Walkthrough

The predicates crate provides a fluent, composable API for creating reusable test assertions and conditions. Instead of writing repetitive assertion logic, you build predicates that can be combined with boolean operators, applied to different types, and used for both testing and runtime filtering. This leads to more readable tests and reduces boilerplate code.

Key concepts:

  1. Predicate trait — the core abstraction for conditions that evaluate to true/false
  2. Combinatorsand(), or(), not() for combining predicates
  3. Built-in predicates — equality, comparison, string matching, path operations
  4. BoxPredicate — type-erased predicate for storage and dynamic dispatch
  5. Assertion output — detailed diff-like output for test failures

Code Example

# Cargo.toml
[dev-dependencies]
predicates = "3.0"
use predicates::prelude::*;
 
fn main() {
    let predicate = predicate::eq(5);
    
    assert!(predicate.eval(&5));
    assert!(!predicate.eval(&10));
    
    let combined = predicate::gt(3).and(predicate::lt(10));
    assert!(combined.eval(&5));
    println!("All tests passed!");
}

Basic Predicates

use predicates::prelude::*;
 
fn main() {
    // Equality
    let equals_five = predicate::eq(5);
    println!("5 equals 5: {}", equals_five.eval(&5));
    println!("10 equals 5: {}", equals_five.eval(&10));
    
    // Comparison
    let greater_than_three = predicate::gt(3);
    println!("5 > 3: {}", greater_than_three.eval(&5));
    println!("2 > 3: {}", greater_than_three.eval(&2));
    
    let less_than_ten = predicate::lt(10);
    let between = predicate::gt(3).and(predicate::lt(10));
    println!("5 is between 3 and 10: {}", between.eval(&5));
    
    // Inequality
    let not_five = predicate::ne(5);
    println!("7 != 5: {}", not_five.eval(&7));
    
    // Greater or equal, less or equal
    let at_least_five = predicate::ge(5);
    println!("5 >= 5: {}", at_least_five.eval(&5));
    
    let at_most_ten = predicate::le(10);
    println!("10 <= 10: {}", at_most_ten.eval(&10));
}

String Predicates

use predicates::prelude::*;
 
fn main() {
    let text = "Hello, World!";
    
    // Exact match
    let is_hello = predicate::eq("Hello");
    println!("'Hello' matches: {}", is_hello.eval(&"Hello"));
    println!("'Hello, World!' matches: {}", is_hello.eval(&text));
    
    // Contains substring
    let contains_world = predicate::str::contains("World");
    println!("Contains 'World': {}", contains_world.eval(text));
    println!("Contains 'Rust': {}", contains_world.eval("Hello, Rust!"));
    
    // Starts with / ends with
    let starts_hello = predicate::str::starts_with("Hello");
    println!("Starts with 'Hello': {}", starts_hello.eval(text));
    
    let ends_world = predicate::str::ends_with("World!");
    println!("Ends with 'World!': {}", ends_world.eval(text));
    
    // Regex matching
    let is_email = predicate::str::is_match(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap();
    println!("Is email (valid): {}", is_email.eval("user@example.com"));
    println!("Is email (invalid): {}", is_email.eval("not-an-email"));
    
    // Case-insensitive contains
    let contains_case_insensitive = predicate::str::contains("rust").trim();
    let contains_normalized = predicate::str::similar("rust");
}

Boolean Combinators

use predicates::prelude::*;
 
fn main() {
    // AND
    let between_5_and_10 = predicate::ge(5).and(predicate::le(10));
    println!("5 is between 5-10: {}", between_5_and_10.eval(&5));
    println!("7 is between 5-10: {}", between_5_and_10.eval(&7));
    println!("15 is between 5-10: {}", between_5_and_10.eval(&15));
    
    // OR
    let less_than_5_or_greater_than_10 = predicate::lt(5).or(predicate::gt(10));
    println!("3 is <5 or >10: {}", less_than_5_or_greater_than_10.eval(&3));
    println!("7 is <5 or >10: {}", less_than_5_or_greater_than_10.eval(&7));
    println!("15 is <5 or >10: {}", less_than_5_or_greater_than_10.eval(&15));
    
    // NOT
    let not_zero = predicate::ne(0).not();
    println!("0 is not zero: {}", not_zero.eval(&0));
    println!("5 is not zero: {}", not_zero.eval(&5));
    
    // Complex combinations
    let valid_age = predicate::ge(0).and(predicate::le(120));
    let is_working_age = predicate::ge(18).and(predicate::le(65));
    let is_senior = predicate::gt(65).and(predicate::le(120));
    let is_minor = predicate::lt(18);
    
    println!("\nAge classification:");
    for age in [10, 25, 70, 130, -5] {
        println!("  Age {}:", age);
        println!("    Valid: {}", valid_age.eval(&age));
        println!("    Working age: {}", is_working_age.eval(&age));
        println!("    Senior: {}", is_senior.eval(&age));
        println!("    Minor: {}", is_minor.eval(&age));
    }
}

Path Predicates

use predicates::prelude::*;
use std::path::Path;
 
fn main() {
    // Create test paths
    let existing_file = Path::new("src/main.rs");
    let non_existent = Path::new("nonexistent.txt");
    
    // Exists
    let exists = predicate::path::exists();
    println!("src/main.rs exists: {}", exists.eval(existing_file));
    println!("nonexistent.txt exists: {}", exists.eval(non_existent));
    
    // Is file / is directory
    let is_file = predicate::path::is_file();
    let is_dir = predicate::path::is_dir();
    
    println!("src/main.rs is file: {}", is_file.eval(existing_file));
    println!("src/main.rs is dir: {}", is_dir.eval(existing_file));
    println!("src is dir: {}", is_dir.eval(Path::new("src")));
    
    // File extension
    let is_rust = predicate::path::ext().eq("rs");
    println!("src/main.rs is Rust: {}", is_rust.eval(existing_file));
    
    // File name pattern
    let has_main = predicate::path::file_name().contains("main");
    println!("Contains 'main' in filename: {}", has_main.eval(existing_file));
}

Float Predicates

use predicates::prelude::*;
 
fn main() {
    // Approximate equality for floats
    let near_pi = predicate::float::is_close(3.14159, 0.001);
    
    println!("3.14159 is near π: {}", near_pi.eval(&3.14159));
    println!("3.141 is near π: {}", near_pi.eval(&3.141));
    println!("3.14 is near π: {}", near_pi.eval(&3.14));
    println!("3.0 is near π: {}", near_pi.eval(&3.0));
    
    // Greater than with tolerance
    let greater_than_10 = predicate::gt(10.0);
    println!("10.001 > 10.0: {}", greater_than_10.eval(&10.001));
    println!("9.999 > 10.0: {}", greater_than_10.eval(&9.999));
}

Using with assert!

use predicates::prelude::*;
 
fn process_number(n: i32) -> i32 {
    n * 2 + 1
}
 
#[cfg(test)]
mod tests {
    use super::*;
    use predicates::prelude::*;
    
    #[test]
    fn test_process_number() {
        let result = process_number(5);
        
        // Using predicate with assert!
        assert!(predicate::eq(11).eval(&result));
        
        // Chained predicates
        let is_odd_and_positive = predicate::gt(0).and(|n: &i32| n % 2 == 1);
        assert!(is_odd_and_positive.eval(&result));
    }
    
    #[test]
    fn test_result_range() {
        let result = process_number(10);
        
        // Result should be between 0 and 100
        let in_range = predicate::ge(0).and(predicate::le(100));
        assert!(in_range.eval(&result));
    }
}
 
fn main() {
    println!("Run with: cargo test");
}

Custom Predicates

use predicates::prelude::*;
use std::fmt;
 
// Method 1: Closure-based predicate
fn is_even() -> impl Predicate<i32> {
    predicate::function(|n: &i32| n % 2 == 0)
}
 
// Method 2: Struct implementing Predicate
struct IsPrime;
 
impl Predicate<i32> for IsPrime {
    fn eval(&self, n: &i32) -> bool {
        if *n <= 1 {
            return false;
        }
        if *n <= 3 {
            return true;
        }
        if *n % 2 == 0 || *n % 3 == 0 {
            return false;
        }
        let mut i = 5;
        while i * i <= *n {
            if *n % i == 0 || *n % (i + 2) == 0 {
                return false;
            }
            i += 6;
        }
        true
    }
    
    fn find_case<'a>(&'a self, expected: bool, variable: &i32) -> Option<Case<'a, i32>> {
        let result = self.eval(variable);
        if result == expected {
            None
        } else {
            Some(Case::new(Some(variable), expected))
        }
    }
}
 
impl fmt::Display for IsPrime {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "var.is_prime()")
    }
}
 
// Method 3: Using predicate::function with description
fn is_positive() -> impl Predicate<i32> {
    predicate::function(|n: &i32| *n > 0).with_fn(|_| "var.is_positive()")
}
 
fn main() {
    // Test is_even
    let even = is_even();
    println!("4 is even: {}", even.eval(&4));
    println!("7 is even: {}", even.eval(&7));
    
    // Test IsPrime
    let prime = IsPrime;
    for n in [1, 2, 3, 4, 5, 11, 12, 17, 18] {
        println!("{} is prime: {}", n, prime.eval(&n));
    }
    
    // Combine custom predicates
    let even_and_positive = is_even().and(is_positive());
    for n in [-2, -1, 0, 1, 2, 3, 4] {
        println!("{} is even and positive: {}", n, even_and_positive.eval(&n));
    }
}

Filtering Collections

use predicates::prelude::*;
 
fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
    
    // Filter with predicate
    let greater_than_10 = predicate::gt(10);
    let big_numbers: Vec<&i32> = numbers.iter()
        .filter(|n| greater_than_10.eval(n))
        .collect();
    println!("Numbers > 10: {:?}", big_numbers);
    
    // Complex predicate
    let is_multiple_of_3_or_5 = predicate::function(|n: &i32| n % 3 == 0 || n % 5 == 0);
    let multiples: Vec<&i32> = numbers.iter()
        .filter(|n| is_multiple_of_3_or_5.eval(n))
        .collect();
    println!("Multiples of 3 or 5: {:?}", multiples);
    
    // String filtering
    let words = vec!["apple", "banana", "cherry", "date", "elderberry"];
    let starts_with_vowel = predicate::str::starts_with("a")
        .or(predicate::str::starts_with("e"))
        .or(predicate::str::starts_with("i"))
        .or(predicate::str::starts_with("o"))
        .or(predicate::str::starts_with("u"));
    
    let vowel_words: Vec<&&str> = words.iter()
        .filter(|w| starts_with_vowel.eval(w))
        .collect();
    println!("Words starting with vowel: {:?}", vowel_words);
    
    // Filter with length predicate
    let long_word = predicate::function(|s: &&str| s.len() > 5);
    let long_words: Vec<&&str> = words.iter()
        .filter(|w| long_word.eval(w))
        .collect();
    println!("Words longer than 5 chars: {:?}", long_words);
}

Boxed Predicates

use predicates::prelude::*;
 
fn main() {
    // BoxPredicate allows dynamic dispatch and storage
    let mut predicates: Vec<BoxPredicate<&str>> = Vec::new();
    
    predicates.push(BoxPredicate::new(predicate::str::contains("hello")));
    predicates.push(BoxPredicate::new(predicate::str::starts_with("test")));
    predicates.push(BoxPredicate::new(predicate::str::ends_with("world")));
    
    let test_cases = vec![
        "hello world",
        "test case",
        "big world",
        "test hello world",
    ];
    
    for test in test_cases {
        println!("Testing: '{}'", test);
        for (i, pred) in predicates.iter().enumerate() {
            println!("  Predicate {}: {}", i, pred.eval(&test));
        }
    }
}

Testing with Predicates

use predicates::prelude::*;
 
struct User {
    name: String,
    age: u32,
    email: String,
}
 
impl User {
    fn new(name: &str, age: u32, email: &str) -> Self {
        Self {
            name: name.to_string(),
            age,
            email: email.to_string(),
        }
    }
    
    fn is_adult(&self) -> bool {
        self.age >= 18
    }
}
 
// Predicates for User
fn is_adult_user() -> impl Predicate<User> {
    predicate::function(|u: &User| u.is_adult())
}
 
fn has_valid_email() -> impl Predicate<User> {
    predicate::function(|u: &User| u.email.contains('@'))
}
 
fn is_named(expected: &str) -> impl Predicate<User> + '_ {
    predicate::function(move |u: &User| u.name == expected)
}
 
fn main() {
    let alice = User::new("Alice", 25, "alice@example.com");
    let bob = User::new("Bob", 15, "bob@example.com");
    let charlie = User::new("Charlie", 30, "invalid-email");
    
    // Test adult
    println!("Alice is adult: {}", is_adult_user().eval(&alice));
    println!("Bob is adult: {}", is_adult_user().eval(&bob));
    
    // Test email validity
    println!("Alice has valid email: {}", has_valid_email().eval(&alice));
    println!("Charlie has valid email: {}", has_valid_email().eval(&charlie));
    
    // Test name
    println!("Is named Alice: {}", is_named("Alice").eval(&alice));
    println!("Is named Bob: {}", is_named("Bob").eval(&alice));
    
    // Combined predicates
    let valid_user = is_adult_user().and(has_valid_email());
    println!("\nUser validation:");
    println!("Alice is valid: {}", valid_user.eval(&alice));
    println!("Bob is valid: {}", valid_user.eval(&bob));
    println!("Charlie is valid: {}", valid_user.eval(&charlie));
}

Iterating with Predicates

use predicates::prelude::*;
 
fn main() {
    let numbers: Vec<i32> = (1..=20).collect();
    
    // Create predicates
    let is_even = predicate::function(|n: &i32| n % 2 == 0);
    let is_divisible_by_3 = predicate::function(|n: &i32| n % 3 == 0);
    let is_divisible_by_6 = is_even.clone().and(is_divisible_by_3.clone());
    
    println!("Even numbers: {:?}", numbers.iter().copied().filter(|n| is_even.eval(n)).collect::<Vec<_>>());
    println!("Divisible by 3: {:?}", numbers.iter().copied().filter(|n| is_divisible_by_3.eval(n)).collect::<Vec<_>>());
    println!("Divisible by 6: {:?}", numbers.iter().copied().filter(|n| is_divisible_by_6.eval(n)).collect::<Vec<_>>());
    
    // Use iter() with predicate
    let divisible_by_4 = predicate::function(|n: &i32| n % 4 == 0);
    for n in numbers.iter().copied().filter(|n| divisible_by_4.eval(n)) {
        println!("Divisible by 4: {}", n);
    }
}

Real-World Example: Input Validation

use predicates::prelude::*;
use std::collections::HashMap;
 
struct FormValidator {
    fields: HashMap<String, String>,
    errors: HashMap<String, Vec<String>>,
}
 
impl FormValidator {
    fn new() -> Self {
        Self {
            fields: HashMap::new(),
            errors: HashMap::new(),
        }
    }
    
    fn field(mut self, name: &str, value: &str) -> Self {
        self.fields.insert(name.to_string(), value.to_string());
        self
    }
    
    fn validate<P: Predicate<str>>(&mut self, field_name: &str, predicate: P, error_msg: &str) {
        if let Some(value) = self.fields.get(field_name) {
            if !predicate.eval(value) {
                self.errors
                    .entry(field_name.to_string())
                    .or_insert_with(Vec::new)
                    .push(error_msg.to_string());
            }
        }
    }
    
    fn is_valid(&self) -> bool {
        self.errors.is_empty()
    }
    
    fn get_errors(&self) -> &HashMap<String, Vec<String>> {
        &self.errors
    }
}
 
fn main() {
    let mut validator = FormValidator::new()
        .field("username", "john_doe")
        .field("email", "john@example.com")
        .field("age", "25");
    
    // Validate username
    validator.validate(
        "username",
        predicate::str::is_match(r"^[a-zA-Z_][a-zA-Z0-9_]{2,15}$").unwrap(),
        "Username must be 3-16 characters, start with letter or underscore"
    );
    validator.validate(
        "username",
        predicate::function(|s: &str| s.len() >= 3),
        "Username must be at least 3 characters"
    );
    
    // Validate email
    validator.validate(
        "email",
        predicate::str::contains("@"),
        "Email must contain @"
    );
    validator.validate(
        "email",
        predicate::str::is_match(r"^[^@]+@[^@]+\.[^@]+$").unwrap(),
        "Email format is invalid"
    );
    
    // Validate age (as string)
    validator.validate(
        "age",
        predicate::str::is_match(r"^[0-9]+$").unwrap(),
        "Age must be a number"
    );
    
    if validator.is_valid() {
        println!("Form is valid!");
    } else {
        println!("Form has errors:");
        for (field, errors) in validator.get_errors() {
            println!("  {}: {}", field, errors.join(", "));
        }
    }
}

Real-World Example: Configuration Matching

use predicates::prelude::*;
 
#[derive(Debug, Clone)]
struct Config {
    environment: String,
    port: u16,
    debug: bool,
    max_connections: usize,
}
 
fn matches_production_requirements() -> impl Predicate<Config> {
    predicate::function(|c: &Config| c.environment == "production")
        .and(predicate::function(|c: &Config| !c.debug))
        .and(predicate::function(|c: &Config| c.port >= 80))
        .and(predicate::function(|c: &Config| c.max_connections >= 100))
}
 
fn matches_development_requirements() -> impl Predicate<Config> {
    predicate::function(|c: &Config| c.environment == "development")
        .and(predicate::function(|c: &Config| c.debug))
}
 
fn is_secure_config() -> impl Predicate<Config> {
    predicate::function(|c: &Config| c.port == 443 || c.environment == "development")
        .and(predicate::function(|c: &Config| !c.debug || c.environment == "development"))
}
 
fn main() {
    let configs = vec![
        Config {
            environment: "production".to_string(),
            port: 443,
            debug: false,
            max_connections: 500,
        },
        Config {
            environment: "development".to_string(),
            port: 8080,
            debug: true,
            max_connections: 50,
        },
        Config {
            environment: "production".to_string(),
            port: 80,
            debug: true, // Wrong!
            max_connections: 200,
        },
    ];
    
    let prod_check = matches_production_requirements();
    let dev_check = matches_development_requirements();
    let secure_check = is_secure_config();
    
    println!("Configuration Analysis:\n");
    for (i, config) in configs.iter().enumerate() {
        println!("Config {} ({:?}):", i + 1, config.environment);
        println!("  Meets production requirements: {}", prod_check.eval(config));
        println!("  Meets development requirements: {}", dev_check.eval(config));
        println!("  Is secure: {}", secure_check.eval(config));
        println!();
    }
}

Real-World Example: Test Assertions with Better Messages

#[cfg(test)]
mod tests {
    use predicates::prelude::*;
    
    fn calculate_score(points: u32, bonus: u32) -> u32 {
        points * 10 + bonus * 5
    }
    
    fn get_status(score: u32) -> &'static str {
        if score >= 100 {
            "expert"
        } else if score >= 50 {
            "intermediate"
        } else {
            "beginner"
        }
    }
    
    #[test]
    fn test_score_calculation() {
        let score = calculate_score(10, 5);
        
        // Using predicates provides better failure messages
        assert!(predicate::eq(125).eval(&score), "Score calculation failed");
        
        // Range check
        let in_expected_range = predicate::ge(100).and(predicate::le(200));
        assert!(in_expected_range.eval(&score));
    }
    
    #[test]
    fn test_status_levels() {
        assert!(predicate::eq("expert").eval(&get_status(150)));
        assert!(predicate::eq("expert").eval(&get_status(100)));
        assert!(predicate::eq("intermediate").eval(&get_status(75)));
        assert!(predicate::eq("intermediate").eval(&get_status(50)));
        assert!(predicate::eq("beginner").eval(&get_status(25)));
    }
    
    #[test]
    fn test_string_output() {
        let output = format!("Score: {}", calculate_score(5, 3));
        
        assert!(predicate::str::contains("Score:").eval(&output));
        assert!(predicate::str::contains("65").eval(&output));
        assert!(predicate::str::starts_with("Score:").eval(&output));
    }
}
 
fn main() {
    println!("Run with: cargo test");
}

Combining with Other Test Frameworks

use predicates::prelude::*;
 
#[cfg(test)]
mod tests {
    use super::*;
    
    // Helper function that returns a predicate
    fn is_valid_username() -> impl Predicate<str> {
        predicate::str::is_match(r"^[a-zA-Z][a-zA-Z0-9_]{2,15}$").unwrap()
            .and(predicate::str::starts_with("user_").not())
    }
    
    fn is_valid_port() -> impl Predicate<u16> {
        predicate::ge(1024).and(predicate::le(65535))
    }
    
    #[test]
    fn test_valid_usernames() {
        let valid_names = vec!["alice", "bob123", "charlie_99", "Admin"];
        let invalid_names = vec!["ab", "user_alice", "123bob", "_invalid"];
        
        for name in valid_names {
            assert!(is_valid_username().eval(name), "Expected '{}' to be valid", name);
        }
        
        for name in invalid_names {
            assert!(!is_valid_username().eval(name), "Expected '{}' to be invalid", name);
        }
    }
    
    #[test]
    fn test_valid_ports() {
        let valid_ports = vec![1024, 8080, 443, 3000];
        let invalid_ports = vec![80, 21, 1023, 65536];
        
        for port in valid_ports {
            assert!(is_valid_port().eval(&port), "Expected {} to be valid", port);
        }
    }
}
 
fn main() {
    println!("Run with: cargo test");
}

Summary

  • predicates provides composable, reusable test assertions with a fluent API
  • Built-in predicates: eq, ne, gt, ge, lt, le for comparisons
  • String predicates: contains, starts_with, ends_with, is_match (regex)
  • Path predicates: exists, is_file, is_dir, ext() for filesystem checks
  • Float predicates: is_close for approximate equality with tolerance
  • Combinators: and(), or(), not() for building complex conditions
  • predicate::function(|x| ...) creates custom predicates from closures
  • BoxPredicate allows storing predicates in collections or using dynamic dispatch
  • Works seamlessly with assert! in tests for readable assertions
  • Use for both testing and runtime filtering/validation
  • Detailed failure output helps diagnose test failures
  • Reduces boilerplate in tests by reusing predicate definitions