Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Testing with Stillwater

The Problem

Testing code that uses validation and effects requires:

  • Setting up mock environments repeatedly
  • Writing verbose assertions for Validation types
  • Creating test data and checking results manually
  • Property-based testing for comprehensive coverage

Without proper utilities, test code becomes verbose and repetitive, making tests harder to write and maintain.

The Solution: Testing Utilities

Stillwater provides ergonomic testing utilities that make writing tests concise and expressive:

#![allow(unused)]
fn main() {
use stillwater::prelude::*;

#[test]
fn test_user_validation() {
    let result = validate_user("user@example.com", 25);
    assert_success!(result);
}
}

MockEnv Builder

The MockEnv builder creates test environments by composing dependencies:

#![allow(unused)]
fn main() {
use stillwater::testing::MockEnv;

struct Database {
    users: Vec<User>,
}

struct Config {
    min_age: i32,
}

#[test]
fn test_with_mock_env() {
    // Build a mock environment with multiple dependencies
    let env = MockEnv::new()
        .with(|| Config { min_age: 18 })
        .with(|| Database { users: vec![] })
        .build();

    let ((_, config), db) = env;
    assert_eq!(config.min_age, 18);
    assert!(db.users.is_empty());
}
}

Building Complex Environments

For more complex setups:

#![allow(unused)]
fn main() {
#[test]
fn test_complex_env() {
    let env = MockEnv::new()
        .with(|| Config { min_age: 18 })
        .with(|| Database::with_test_data())
        .with(|| "auth_token_123")
        .build();

    let (((_, config), db), token) = env;

    // Use your mocked environment
    let result = process_request(&config, &db, token);
    assert_success!(result);
}
}

Assertion Macros

Stillwater provides three assertion macros for testing Validation types:

assert_success!

Assert that a validation succeeds:

#![allow(unused)]
fn main() {
#[test]
fn test_valid_email() {
    let result = validate_email("user@example.com");
    assert_success!(result);
}
}

This will panic if the validation is a Failure, showing the errors.

assert_failure!

Assert that a validation fails:

#![allow(unused)]
fn main() {
#[test]
fn test_invalid_email() {
    let result = validate_email("invalid");
    assert_failure!(result);
}
}

This will panic if the validation is a Success.

assert_validation_errors!

Assert that a validation fails with specific errors:

#![allow(unused)]
fn main() {
#[test]
fn test_specific_errors() {
    let result = validate_email("invalid");
    assert_validation_errors!(
        result,
        vec!["Email must contain @".to_string()]
    );
}
}

This checks both that the validation failed AND that the errors match exactly.

Testing Patterns

Testing Error Accumulation

Validation accumulates all errors:

#![allow(unused)]
fn main() {
#[test]
fn test_accumulates_all_errors() {
    let result = validate_user("invalid", 15);
    assert_failure!(result);

    match result {
        Validation::Failure(errors) => {
            assert_eq!(errors.len(), 2);
            assert!(errors.contains(&"Invalid email".to_string()));
            assert!(errors.contains(&"Must be 18+".to_string()));
        }
        _ => panic!("Expected failure"),
    }
}
}

Testing Validation Composition

Test how validations combine:

#![allow(unused)]
fn main() {
#[test]
fn test_validation_and() {
    let v1 = validate_email("user@example.com");
    let v2 = validate_age(25);

    let result = v1.and(v2);
    assert_success!(result);

    match result {
        Validation::Success((email, age)) => {
            assert_eq!(email, "user@example.com");
            assert_eq!(age, 25);
        }
        _ => panic!("Expected success"),
    }
}
}

Testing with Effects

Test effects using mock environments:

#![allow(unused)]
fn main() {
#[test]
fn test_effect_composition() {
    let env = MockEnv::new()
        .with(|| Database::with_test_data())
        .build();

    let (_, db) = env;

    let effect = Effect::from(|_: &Database| {
        Ok::<i32, String>(42)
    });

    let result = effect.run(&db);
    assert_eq!(result, Ok(42));
}
}

Property-Based Testing

Enable property-based testing with the proptest feature:

[dev-dependencies]
proptest = "1.0"

Stillwater provides Arbitrary instances for Validation:

#![allow(unused)]
fn main() {
#[cfg(feature = "proptest")]
mod proptest_tests {
    use super::*;
    use proptest::prelude::*;

    proptest! {
        #[test]
        fn test_validation_map_preserves_success(value: i32) {
            let val = Validation::<_, Vec<String>>::success(value);
            let mapped = val.map(|x| x * 2);
            assert_success!(mapped);
        }

        #[test]
        fn test_email_validation(
            local in "[a-z]{1,10}",
            domain in "[a-z]{1,10}",
            tld in "[a-z]{2,5}"
        ) {
            let email = format!("{}@{}.{}", local, domain, tld);
            let result = validate_email(&email);
            assert_success!(result);
        }
    }
}
}

Property Testing Patterns

Test invariants that should always hold:

#![allow(unused)]
fn main() {
proptest! {
    #[test]
    fn test_success_always_is_success(value: i32) {
        let val = Validation::<_, Vec<String>>::success(value);
        assert!(val.is_success());
    }

    #[test]
    fn test_failure_always_is_failure(errors: Vec<String>) {
        prop_assume!(!errors.is_empty());
        let val = Validation::<i32, _>::failure(errors);
        assert!(val.is_failure());
    }

    #[test]
    fn test_map_preserves_failure(errors: Vec<String>) {
        prop_assume!(!errors.is_empty());
        let val = Validation::<i32, _>::failure(errors);
        let mapped = val.map(|x| x * 2);
        assert_failure!(mapped);
    }
}
}

Testing Best Practices

1. Use Descriptive Test Names

#![allow(unused)]
fn main() {
#[test]
fn test_email_validation_rejects_missing_at_symbol() {
    let result = validate_email("invalid.com");
    assert_failure!(result);
}
}

2. Test Both Success and Failure Cases

#![allow(unused)]
fn main() {
#[test]
fn test_age_validation_accepts_adults() {
    let result = validate_age(18);
    assert_success!(result);
}

#[test]
fn test_age_validation_rejects_minors() {
    let result = validate_age(17);
    assert_failure!(result);
}
}

3. Test Error Accumulation Explicitly

#![allow(unused)]
fn main() {
#[test]
fn test_validates_all_fields_at_once() {
    let result = validate_user("invalid", 15);

    match result {
        Validation::Failure(errors) => {
            assert!(errors.len() > 1, "Should accumulate multiple errors");
        }
        _ => panic!("Expected validation to fail"),
    }
}
}

4. Use MockEnv for Complex Dependencies

#![allow(unused)]
fn main() {
#[test]
fn test_with_realistic_environment() {
    let env = MockEnv::new()
        .with(|| Config::from_env())
        .with(|| Database::with_fixtures())
        .with(|| Cache::empty())
        .build();

    // Test your code with the full environment
}
}

Integration with Test Frameworks

Using with Standard Test Framework

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use stillwater::prelude::*;

    #[test]
    fn test_my_function() {
        let result = my_validation_function();
        assert_success!(result);
    }
}
}

Using with Tokio Test

For async tests:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod async_tests {
    use super::*;
    use stillwater::prelude::*;

    #[tokio::test]
    async fn test_async_validation() {
        let result = async_validate().await;
        assert_success!(result);
    }
}
}

Complete Example

Here’s a complete example showing all testing utilities:

#![allow(unused)]
fn main() {
use stillwater::prelude::*;

#[derive(Debug, Clone, PartialEq)]
struct User {
    email: String,
    age: i32,
}

fn validate_email(email: &str) -> Validation<String, Vec<String>> {
    if email.contains('@') {
        Validation::success(email.to_string())
    } else {
        Validation::failure(vec!["Invalid email".to_string()])
    }
}

fn validate_age(age: i32) -> Validation<i32, Vec<String>> {
    if age >= 18 {
        Validation::success(age)
    } else {
        Validation::failure(vec!["Must be 18+".to_string()])
    }
}

fn validate_user(email: &str, age: i32) -> Validation<User, Vec<String>> {
    Validation::all((validate_email(email), validate_age(age)))
        .map(|(email, age)| User { email, age })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_valid_user() {
        let result = validate_user("user@example.com", 25);
        assert_success!(result);
    }

    #[test]
    fn test_invalid_email() {
        let result = validate_user("invalid", 25);
        assert_failure!(result);
    }

    #[test]
    fn test_underage() {
        let result = validate_user("user@example.com", 15);
        assert_failure!(result);
    }

    #[test]
    fn test_multiple_errors() {
        let result = validate_user("invalid", 15);

        match result {
            Validation::Failure(errors) => {
                assert_eq!(errors.len(), 2);
            }
            _ => panic!("Expected failure"),
        }
    }
}
}

Next Steps