How do I write predicate tests with the predicates crate in Rust?

Walkthrough

The predicates crate provides a functional, composable way to define predicates (boolean-valued functions) that can be used for testing and assertions. Instead of writing custom assertion logic, you chain predicates together to create expressive, readable tests. The crate integrates seamlessly with assertion libraries and provides clear failure messages. Predicates can test equality, ordering, string contents, path properties, and can be combined with boolean operators (and, or, not). This leads to more maintainable tests with better error messages when they fail.

Key concepts:

  1. Predicate trait — defines a function that returns true/false for a value
  2. Composability — combine predicates with and(), or(), not()
  3. Built-in predicates — equality, ordering, string matching, path properties
  4. Custom predicates — implement Predicate trait for domain-specific logic
  5. Integration — works with assert!(pred.eval(value)) pattern

Code Example

# Cargo.toml
[dependencies]
predicates = "3"
use predicates::prelude::*;
 
fn main() {
    let predicate = predicate::eq(5);
    
    assert!(predicate.eval(&5));
    assert!(!predicate.eval(&3));
    
    println!("Is 5 equal to 5? {}", predicate.eval(&5));
}

Basic Equality Predicates

use predicates::prelude::*;
 
fn main() {
    // Equality
    let is_five = predicate::eq(5);
    println!("5 == 5: {}", is_five.eval(&5));
    println!("3 == 5: {}", is_five.eval(&3));
    
    // Inequality
    let not_five = predicate::ne(5);
    println!("3 != 5: {}", not_five.eval(&3));
    
    // Equality with floating point tolerance
    let approx_pi = predicate::near(3.14159).epsilon(0.0001);
    println!("pi is approximately 3.14159: {}", approx_pi.eval(&3.14160));
    
    // Works with strings
    let is_hello = predicate::eq("hello");
    println!("'hello' == 'hello': {}", is_hello.eval("hello"));
}

Ordering Predicates

use predicates::prelude::*;
 
fn main() {
    // Greater than
    let is_positive = predicate::gt(0);
    println!("5 > 0: {}", is_positive.eval(&5));
    println!("-1 > 0: {}", is_positive.eval(&-1));
    
    // Greater than or equal
    let at_least_ten = predicate::ge(10);
    println!("10 >= 10: {}", at_least_ten.eval(&10));
    println!("5 >= 10: {}", at_least_ten.eval(&5));
    
    // Less than
    let is_child = predicate::lt(18);
    println!("12 < 18: {}", is_child.eval(&12));
    
    // Less than or equal
    let at_most_hundred = predicate::le(100);
    println!("100 <= 100: {}", at_most_hundred.eval(&100));
}

String Predicates

use predicates::prelude::*;
 
fn main() {
    // Contains substring
    let contains_world = predicate::str::contains("world");
    println!("'hello world' contains 'world': {}", contains_world.eval("hello world"));
    println!("'hello there' contains 'world': {}", contains_world.eval("hello there"));
    
    // Starts with
    let starts_hello = predicate::str::starts_with("hello");
    println!("'hello world' starts with 'hello': {}", starts_hello.eval("hello world"));
    
    // Ends with
    let ends_rust = predicate::str::ends_with(".rs");
    println!("'main.rs' ends with '.rs': {}", ends_rust.eval("main.rs"));
    
    // Matches regex
    let is_email = predicate::str::is_match(r"^[a-z]+@[a-z]+\.[a-z]+$").unwrap();
    println!("'user@example.com' matches email pattern: {}", is_email.eval("user@example.com"));
    println!("'not-an-email' matches email pattern: {}", is_email.eval("not-an-email"));
    
    // Is empty
    let is_empty = predicate::str::is_empty();
    println!("'' is empty: {}", is_empty.eval(""));
    println!("'text' is empty: {}", is_empty.eval("text"));
    
    // Is not empty
    let is_not_empty = predicate::str::is_empty().not();
    println!("'text' is not empty: {}", is_not_empty.eval("text"));
}

Case-Insensitive String Predicates

use predicates::prelude::*;
 
fn main() {
    // Case-insensitive contains
    let contains_hello = predicate::str::contains("HELLO").trim().normalize();
    println!("'hello world' contains 'HELLO' (normalized): {}", 
             contains_hello.eval("hello world"));
}

Path Predicates

use predicates::prelude::*;
use std::path::Path;
 
fn main() {
    // Exists
    let exists = predicate::path::exists();
    println!("'/etc/hosts' exists: {}", exists.eval(Path::new("/etc/hosts")));
    
    // Is a file
    let is_file = predicate::path::is_file();
    println!("'/etc/hosts' is file: {}", is_file.eval(Path::new("/etc/hosts")));
    
    // Is a directory
    let is_dir = predicate::path::is_dir();
    println!("'/etc' is directory: {}", is_dir.eval(Path::new("/etc")));
    
    // File extension
    let is_rust = predicate::path::extension().eq("rs");
    println!("'main.rs' has extension 'rs': {}", is_rust.eval(Path::new("main.rs")));
    
    // File name
    let is_config = predicate::path::file_name().eq("config.json");
    println!("'/app/config.json' has filename 'config.json': {}", 
             is_config.eval(Path::new("/app/config.json")));
}

Combining Predicates with AND

use predicates::prelude::*;
 
fn main() {
    // Both conditions must be true
    let is_adult = predicate::ge(18);
    let is_senior = predicate::lt(65);
    let is_working_age = is_adult.and(is_senior);
    
    println!("25 is working age: {}", is_working_age.eval(&25));
    println!("10 is working age: {}", is_working_age.eval(&10));
    println!("70 is working age: {}", is_working_age.eval(&70));
    
    // Chain multiple conditions
    let is_valid_score = predicate::ge(0).and(predicate::le(100));
    println!("50 is valid score: {}", is_valid_score.eval(&50));
    println!("150 is valid score: {}", is_valid_score.eval(&150));
}

Combining Predicates with OR

use predicates::prelude::*;
 
fn main() {
    // Either condition can be true
    let is_weekend = predicate::eq("Saturday").or(predicate::eq("Sunday"));
    
    println!("Saturday is weekend: {}", is_weekend.eval("Saturday"));
    println!("Sunday is weekend: {}", is_weekend.eval("Sunday"));
    println!("Monday is weekend: {}", is_weekend.eval("Monday"));
    
    // Complex OR chain
    let is_holiday = predicate::eq("Christmas")
        .or(predicate::eq("Thanksgiving"))
        .or(predicate::eq("New Year"));
    
    println!("Christmas is holiday: {}", is_holiday.eval("Christmas"));
    println!("Tuesday is holiday: {}", is_holiday.eval("Tuesday"));
}

Negating Predicates with NOT

use predicates::prelude::*;
 
fn main() {
    // Negate a predicate
    let is_empty = predicate::str::is_empty();
    let is_not_empty = is_empty.not();
    
    println!("'' is not empty: {}", is_not_empty.eval(""));
    println!("'text' is not empty: {}", is_not_empty.eval("text"));
    
    // Negate complex predicates
    let is_admin = predicate::eq("admin");
    let is_not_admin = is_admin.not();
    
    println!("'user' is not admin: {}", is_not_admin.eval("user"));
    println!("'admin' is not admin: {}", is_not_admin.eval("admin"));
}

Complex Combinations

use predicates::prelude::*;
 
fn main() {
    // Valid age: 0-17 (minor) OR 18-64 (adult) OR 65+ (senior)
    // Let's say we want to check if someone is NOT a minor
    let is_minor = predicate::lt(18);
    let is_not_minor = is_minor.not();
    
    println!("20 is not minor: {}", is_not_minor.eval(&20));
    println!("10 is not minor: {}", is_not_minor.eval(&10));
    
    // Valid password: at least 8 chars AND contains number AND contains letter
    let has_min_length = predicate::str::len().ge(8);
    let has_digit = predicate::str::contains("1")
        .or(predicate::str::contains("2"))
        .or(predicate::str::contains("3"))
        .or(predicate::str::contains("4"))
        .or(predicate::str::contains("5"))
        .or(predicate::str::contains("6"))
        .or(predicate::str::contains("7"))
        .or(predicate::str::contains("8"))
        .or(predicate::str::contains("9"))
        .or(predicate::str::contains("0"));
    
    // Simplified: just check length for demo
    let valid_password = has_min_length;
    println!("'password123' is valid: {}", valid_password.eval("password123"));
    println!("'pass' is valid: {}", valid_password.eval("pass"));
}

Floating Point Predicates

use predicates::prelude::*;
 
fn main() {
    // Approximate equality with epsilon
    let is_close = predicate::near(3.14159).epsilon(0.01);
    
    println!("3.14 is close to 3.14159 (epsilon=0.01): {}", is_close.eval(&3.14));
    println!("3.0 is close to 3.14159 (epsilon=0.01): {}", is_close.eval(&3.0));
    
    // Different epsilon values
    let is_approx_pi = predicate::near(3.14159).epsilon(0.001);
    println!("3.142 is approximately pi: {}", is_approx_pi.eval(&3.142));
}

Iteration Predicates

use predicates::prelude::*;
 
fn main() {
    // Check if any element matches
    let has_positive = predicate::iter::any(predicate::gt(0));
    println!("[-1, 0, 1] has positive: {}", has_positive.eval(&[-1, 0, 1]));
    println!("[-1, -2, -3] has positive: {}", has_positive.eval(&[-1, -2, -3]));
    
    // Check if all elements match
    let all_positive = predicate::iter::all(predicate::gt(0));
    println!("[1, 2, 3] all positive: {}", all_positive.eval(&[1, 2, 3]));
    println!("[1, 0, 3] all positive: {}", all_positive.eval(&[1, 0, 3]));
    
    // Check if iterator contains a specific value
    let contains_five = predicate::iter::contains(5);
    println!("[1, 2, 5, 3] contains 5: {}", contains_five.eval(&[1, 2, 5, 3]));
}

Boolean Predicates

use predicates::prelude::*;
 
fn main() {
    // Always true/false
    let always_true = predicate::always();
    let always_false = predicate::never();
    
    println!("always_true: {}", always_true.eval(&42));
    println!("always_false: {}", always_false.eval(&42));
    
    // Boolean function predicate
    let is_even = predicate::function(|x: &i32| x % 2 == 0);
    println!("4 is even: {}", is_even.eval(&4));
    println!("3 is even: {}", is_even.eval(&3));
}

Function Predicates

use predicates::prelude::*;
 
fn main() {
    // Custom function as predicate
    let is_long_enough = predicate::function(|s: &&str| s.len() >= 5);
    println!("'hello' is long enough: {}", is_long_enough.eval("hello"));
    println!("'hi' is long enough: {}", is_long_enough.eval("hi"));
    
    // Complex logic
    let is_palindrome = predicate::function(|s: &&str| {
        let chars: Vec<char> = s.chars().collect();
        chars == chars.iter().rev().cloned().collect::<Vec<_>>()
    });
    
    println!("'racecar' is palindrome: {}", is_palindrome.eval("racecar"));
    println!("'hello' is palindrome: {}", is_palindrome.eval("hello"));
}

Box Predicates (Dynamic Dispatch)

use predicates::prelude::*;
use predicates::BoxPredicate;
 
fn main() {
    // Create a boxed predicate for runtime selection
    fn get_validator(min: i32, max: i32) -> BoxPredicate<i32> {
        predicate::ge(min).and(predicate::le(max)).boxed()
    }
    
    let validator = get_validator(10, 100);
    println!("50 is in range: {}", validator.eval(&50));
    println!("5 is in range: {}", validator.eval(&5));
    println!("150 is in range: {}", validator.eval(&150));
}

Real-World: User Validation

use predicates::prelude::*;
 
struct User {
    username: String,
    email: String,
    age: u32,
    role: String,
}
 
fn validate_user(user: &User) -> Result<(), String> {
    let valid_username = predicate::str::len().ge(3)
        .and(predicate::str::len().le(20));
    
    let valid_email = predicate::str::contains("@");
    
    let valid_age = predicate::ge(13).and(predicate::le(120));
    
    let valid_role = predicate::eq("admin")
        .or(predicate::eq("user"))
        .or(predicate::eq("guest"));
    
    if !valid_username.eval(&user.username.as_str()) {
        return Err("Username must be 3-20 characters".to_string());
    }
    
    if !valid_email.eval(&user.email.as_str()) {
        return Err("Invalid email format".to_string());
    }
    
    if !valid_age.eval(&user.age) {
        return Err("Age must be 13-120".to_string());
    }
    
    if !valid_role.eval(&user.role.as_str()) {
        return Err("Invalid role".to_string());
    }
    
    Ok(())
}
 
fn main() {
    let good_user = User {
        username: "alice".to_string(),
        email: "alice@example.com".to_string(),
        age: 25,
        role: "user".to_string(),
    };
    
    let bad_user = User {
        username: "ab".to_string(), // too short
        email: "invalid-email".to_string(),
        age: 10, // too young
        role: "superadmin".to_string(), // invalid role
    };
    
    match validate_user(&good_user) {
        Ok(()) => println!("Good user is valid!"),
        Err(e) => println!("Good user error: {}", e),
    }
    
    match validate_user(&bad_user) {
        Ok(()) => println!("Bad user is valid!"),
        Err(e) => println!("Bad user error: {}", e),
    }
}

Real-World: Configuration Validation

use predicates::prelude::*;
use std::collections::HashMap;
 
struct Config {
    settings: HashMap<String, String>,
}
 
impl Config {
    fn new() -> Self {
        Self { settings: HashMap::new() }
    }
    
    fn set(&mut self, key: &str, value: &str) {
        self.settings.insert(key.to_string(), value.to_string());
    }
    
    fn get(&self, key: &str) -> Option<&String> {
        self.settings.get(key)
    }
}
 
fn validate_config(config: &Config) -> Vec<String> {
    let mut errors = Vec::new();
    
    // Required keys
    let required_keys = ["host", "port", "database"];
    
    for key in required_keys {
        let key_exists = predicate::path::exists();
        if config.get(key).is_none() {
            errors.push(format!("Missing required key: {}", key));
        }
    }
    
    // Validate port is numeric
    if let Some(port) = config.get("port") {
        let is_numeric = predicate::str::is_match(r"^\d+$").unwrap();
        if !is_numeric.eval(port) {
            errors.push("Port must be numeric".to_string());
        }
        
        let valid_port = predicate::function(|p: &&str| {
            p.parse::<u16>().is_ok()
        });
        if !valid_port.eval(port) {
            errors.push("Port must be 1-65535".to_string());
        }
    }
    
    // Validate host is not empty
    if let Some(host) = config.get("host") {
        let is_not_empty = predicate::str::is_empty().not();
        if !is_not_empty.eval(host) {
            errors.push("Host cannot be empty".to_string());
        }
    }
    
    errors
}
 
fn main() {
    let mut config = Config::new();
    config.set("host", "localhost");
    config.set("port", "8080");
    config.set("database", "mydb");
    
    let errors = validate_config(&config);
    if errors.is_empty() {
        println!("Config is valid!");
    } else {
        println!("Config errors: {:?}", errors);
    }
    
    let mut bad_config = Config::new();
    bad_config.set("host", "");
    bad_config.set("port", "abc");
    
    let errors = validate_config(&bad_config);
    println!("Bad config errors: {:?}", errors);
}

Real-World: API Response Validation

use predicates::prelude::*;
 
struct ApiResponse {
    status: u16,
    body: String,
    headers: Vec<(String, String)>,
}
 
fn validate_response(response: &ApiResponse) -> Result<(), String> {
    // Status should be 2xx
    let is_success_status = predicate::ge(200).and(predicate::lt(300));
    
    if !is_success_status.eval(&response.status) {
        return Err(format!("Unexpected status: {}", response.status));
    }
    
    // Body should be valid JSON (simplified check)
    let is_json = predicate::str::starts_with("{")
        .or(predicate::str::starts_with("["));
    
    if !is_json.eval(&response.body.as_str()) {
        return Err("Response body is not JSON".to_string());
    }
    
    // Should have content-type header
    let has_content_type = predicate::function(|headers: &Vec<(String, String)>| {
        headers.iter().any(|(k, _)| k.to_lowercase() == "content-type")
    });
    
    if !has_content_type.eval(&response.headers) {
        return Err("Missing Content-Type header".to_string());
    }
    
    Ok(())
}
 
fn main() {
    let good_response = ApiResponse {
        status: 200,
        body: r#"{"message": "ok"}"#.to_string(),
        headers: vec![
            ("Content-Type".to_string(), "application/json".to_string()),
        ],
    };
    
    let bad_response = ApiResponse {
        status: 500,
        body: "Internal Server Error".to_string(),
        headers: vec![],
    };
    
    match validate_response(&good_response) {
        Ok(()) => println!("Good response is valid!"),
        Err(e) => println!("Good response error: {}", e),
    }
    
    match validate_response(&bad_response) {
        Ok(()) => println!("Bad response is valid!"),
        Err(e) => println!("Bad response error: {}", e),
    }
}

Real-World: File Filter

use predicates::prelude::*;
use std::path::Path;
 
fn create_file_filter() -> impl Predicate<Path> {
    // Rust or Markdown files
    let is_rust = predicate::path::extension().eq("rs");
    let is_markdown = predicate::path::extension().eq("md");
    let is_code = is_rust.or(is_markdown);
    
    // Not in target directory
    let not_in_target = predicate::path::ends_with("target").not();
    
    is_code.and(not_in_target)
}
 
fn main() {
    let filter = create_file_filter();
    
    let files = vec![
        Path::new("src/main.rs"),
        Path::new("README.md"),
        Path::new("target/main.rs"),
        Path::new("Cargo.toml"),
        Path::new("docs/guide.md"),
    ];
    
    println!("Files matching filter:");
    for file in files {
        if filter.eval(file) {
            println!("  {}", file.display());
        }
    }
}

Real-World: Data Quality Checks

use predicates::prelude::*;
 
struct Record {
    id: i32,
    name: String,
    score: f64,
    tags: Vec<String>,
}
 
fn check_data_quality(records: &[Record]) -> Vec<String> {
    let mut issues = Vec::new();
    
    let valid_id = predicate::gt(0);
    let valid_name = predicate::str::len().ge(1).and(predicate::str::len().le(100));
    let valid_score = predicate::ge(0.0).and(predicate::le(100.0));
    let has_tags = predicate::function(|tags: &Vec<String>| !tags.is_empty());
    
    for (i, record) in records.iter().enumerate() {
        if !valid_id.eval(&record.id) {
            issues.push(format!("Record {}: Invalid ID {}", i, record.id));
        }
        
        if !valid_name.eval(&record.name.as_str()) {
            issues.push(format!("Record {}: Invalid name '{}'", i, record.name));
        }
        
        if !valid_score.eval(&record.score) {
            issues.push(format!("Record {}: Invalid score {}", i, record.score));
        }
        
        if !has_tags.eval(&record.tags) {
            issues.push(format!("Record {}: No tags", i));
        }
    }
    
    issues
}
 
fn main() {
    let records = vec![
        Record {
            id: 1,
            name: "Alice".to_string(),
            score: 95.5,
            tags: vec!["vip".to_string()],
        },
        Record {
            id: -1, // Invalid
            name: "".to_string(), // Invalid
            score: 150.0, // Invalid
            tags: vec![], // Invalid
        },
        Record {
            id: 2,
            name: "Bob".to_string(),
            score: 75.0,
            tags: vec!["new".to_string()],
        },
    ];
    
    let issues = check_data_quality(&records);
    
    if issues.is_empty() {
        println!("All records pass quality checks!");
    } else {
        println!("Data quality issues:");
        for issue in issues {
            println!("  - {}", issue);
        }
    }
}

Real-World: Test Assertions

use predicates::prelude::*;
 
// Simulated test functions
fn assert_true(condition: bool, message: &str) {
    if condition {
        println!("✓ {}", message);
    } else {
        println!("✗ {}", message);
    }
}
 
#[test]
fn test_user_operations() {
    let result = 42;
    
    // Using predicates for assertions
    assert_true(
        predicate::eq(42).eval(&result),
        "Result should be 42"
    );
    
    assert_true(
        predicate::gt(40).and(predicate::lt(50)).eval(&result),
        "Result should be between 40 and 50"
    );
}
 
#[test]
fn test_string_operations() {
    let message = "Hello, World!";
    
    assert_true(
        predicate::str::contains("World").eval(message),
        "Message should contain 'World'"
    );
    
    assert_true(
        predicate::str::starts_with("Hello").eval(message),
        "Message should start with 'Hello'"
    );
    
    assert_true(
        predicate::str::len().eq(13).eval(message),
        "Message should have length 13"
    );
}
 
fn main() {
    test_user_operations();
    test_string_operations();
}

Debug Output

use predicates::prelude::*;
 
fn main() {
    let pred = predicate::gt(5).and(predicate::lt(10));
    
    // The predicate can be displayed
    println!("Predicate: {}", pred);
    
    // Debug output shows the structure
    println!("Debug: {:?}", pred);
    
    // Complex predicate
    let complex = predicate::str::contains("hello")
        .or(predicate::str::contains("hi"))
        .and(predicate::str::len().ge(3));
    
    println!("Complex predicate: {}", complex);
}

Summary

  • predicate::eq(value) creates an equality predicate
  • predicate::gt/ge/lt/le(value) create ordering predicates
  • predicate::str::contains/starts_with/ends_with(text) for string matching
  • predicate::str::is_match(regex) for regex matching
  • predicate::path::exists/is_file/is_dir() for path checks
  • .and(other) combines predicates with AND
  • .or(other) combines predicates with OR
  • .not() negates a predicate
  • predicate::iter::any/all(pred) for collection checking
  • predicate::function(|x| ...) for custom logic
  • .boxed() creates a BoxPredicate for dynamic dispatch
  • pred.eval(&value) evaluates the predicate
  • Composable, readable, and produces good failure messages
  • Perfect for validation, filtering, and testing assertions