Purpose of This Chapter

Ensuring the correctness and reliability of our password generator is paramount. Unit tests allow us to verify that individual components of our application work as expected. In this chapter, we will write basic unit tests for our build_char_pool function and the generate_single_password function to catch regressions and validate our logic.

Concepts Explained

Unit Testing: Testing individual units or components of your code (e.g., functions, methods) in isolation to ensure they behave correctly.

Cargo Test: Rust’s built-in testing framework. You write tests directly in your Rust source files (often in a tests module) and run them using cargo test.

Assertions: Functions or macros (like assert!, assert_eq!, assert_ne!) used within tests to check if a condition is true, if two values are equal, etc. If an assertion fails, the test fails.

#[cfg(test)] and mod tests: Conventionally, tests in Rust are placed in a mod tests block annotated with #[cfg(test)]. This attribute tells Rust to compile and run this module only when running tests, not when building the release binary.

Test Functions: Functions annotated with #[test] are recognized by Cargo as test cases.

Step-by-Step Tasks

1. Add Test Module and First Test

Open rpassword-gen/src/main.rs. At the bottom of the file, add a tests module.

// ... (existing code: imports, constants, PasswordGenError, Args struct,
//      build_char_pool function, generate_single_password function, run function)


fn main() {
    env_logger::init();
    info!("Logger initialized.");

    if let Err(e) = run() {
        error!("Application encountered an error: {}", e);
        process::exit(1);
    }
    info!("Application finished successfully.");
}


#[cfg(test)] // This module is only compiled when running tests
mod tests {
    use super::*; // Import everything from the outer scope

    #[test]
    fn test_build_char_pool_all_defaults() {
        let args = Args {
            length: 16,
            count: 1,
            uppercase: false,
            lowercase: false,
            numbers: false,
            symbols: false,
        };
        let pool = build_char_pool(&args);
        // Assert that the pool contains characters from all sets when no flags are set
        assert!(pool.contains(LOWERCASE_CHARS));
        assert!(pool.contains(UPPERCASE_CHARS));
        assert!(pool.contains(NUMERIC_CHARS));
        assert!(pool.contains(SYMBOL_CHARS));
        // Check for expected total length of default combined character set
        assert_eq!(
            pool.len(),
            LOWERCASE_CHARS.len() + UPPERCASE_CHARS.len() + NUMERIC_CHARS.len() + SYMBOL_CHARS.len()
        );
    }
}

Explanation of the new test code:

  • #[cfg(test)]: Ensures this module is only included during testing.
  • mod tests { ... }: Declares a module named tests.
  • use super::*;: Brings all items from the parent scope (our main.rs file) into this test module, allowing us to access Args, build_char_pool, LOWERCASE_CHARS, etc.
  • #[test]: Marks test_build_char_pool_all_defaults as a test function.
  • let args = Args { ... };: We manually create an Args struct with specific values to simulate command-line input.
  • let pool = build_char_pool(&args);: Call the function we want to test.
  • assert!(pool.contains(LOWERCASE_CHARS));: Checks if the generated pool string contains all characters from our LOWERCASE_CHARS constant. This validates that the correct character sets are included.
  • assert_eq!(pool.len(), ...);: Verifies that the length of the combined pool is correct.

2. Add More Tests

Let’s add a few more tests for different scenarios: specific character sets and password generation.

// ... (previous test_build_char_pool_all_defaults test)

    #[test]
    fn test_build_char_pool_specific_types() {
        let args = Args {
            length: 16,
            count: 1,
            uppercase: true,
            lowercase: false,
            numbers: true,
            symbols: false,
        };
        let pool = build_char_pool(&args);
        assert!(pool.contains(UPPERCASE_CHARS));
        assert!(!pool.contains(LOWERCASE_CHARS)); // Ensure not included
        assert!(pool.contains(NUMERIC_CHARS));
        assert!(!pool.contains(SYMBOL_CHARS));    // Ensure not included
        assert_eq!(
            pool.len(),
            UPPERCASE_CHARS.len() + NUMERIC_CHARS.len()
        );
    }

    #[test]
    fn test_generate_single_password_length() {
        let args = Args {
            length: 10,
            count: 1,
            uppercase: true,
            lowercase: true,
            numbers: true,
            symbols: true,
        };
        let pool = build_char_pool(&args);
        let pool_chars: Vec<char> = pool.chars().collect();

        // Need to pass a slice of chars, not String directly
        let password = generate_single_password(&args, &pool_chars);
        assert_eq!(password.len(), 10); // Ensure correct length
    }

    #[test]
    fn test_generate_single_password_valid_chars() {
        let args = Args {
            length: 20,
            count: 1,
            uppercase: true,
            lowercase: true,
            numbers: false,
            symbols: false,
        };
        let pool = build_char_pool(&args);
        let pool_chars: Vec<char> = pool.chars().collect();
        let allowed_chars: Vec<char> = pool.chars().collect();

        let password = generate_single_password(&args, &pool_chars);
        assert_eq!(password.len(), 20);
        // Check if every character in the generated password is within the allowed pool
        for char_in_pwd in password.chars() {
            assert!(allowed_chars.contains(&char_in_pwd), "Password contains disallowed character: {}", char_in_pwd);
        }
    }
} // End of mod tests

3. Run Your Tests

Save src/main.rs and run all tests using Cargo:

cargo test

Expected output:

   Compiling rpassword-gen v0.1.0 (/path/to/rpassword-gen)
    Finished test [unoptimized + debuginfo] target(s) in X.XXs
     Running unittests (target/debug/deps/rpassword_gen-XXXXXXXXXXX)

running 4 tests
test tests::test_build_char_pool_all_defaults ... ok
test tests::test_build_char_pool_specific_types ... ok
test tests::test_generate_single_password_length ... ok
test tests::test_generate_single_password_valid_chars ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

If all tests pass, you’ll see ok for each test and a summary showing 4 passed. If any fail, Cargo will provide detailed information about the failure.

Tips/Challenges/Errors

  • Test Data: When writing unit tests, create specific Args instances (or whatever input your function takes) that represent various scenarios (defaults, specific flags, edge cases).
  • Test Isolation: Unit tests should ideally be isolated, meaning they don’t depend on each other or external factors like network access or file system. Our tests here for build_char_pool and generate_single_password are pure functions, making them easy to test.
  • Randomness in Tests: Testing random output can be tricky.
    • For password length, it’s deterministic (assert_eq!(password.len(), ...)).
    • For character types, we assert that the characters used are from the allowed set. We don’t try to predict the exact random sequence.
  • Integration Tests: For testing how your entire CLI tool works from the command line (e.g., parsing arguments and outputting the final password), you would typically use integration tests in a separate tests/ directory (not src/main.rs). We are focusing on unit tests here for core logic.

Summary/Key Takeaways

In this chapter, you successfully:

  • Learned how to create a #[cfg(test)] module for unit tests.
  • Wrote unit tests for the build_char_pool function, verifying both default and specific character set inclusions.
  • Created unit tests for generate_single_password to confirm correct length and character validity.
  • Ran all your tests using cargo test and ensured they pass.

By adding unit tests, we’ve significantly improved the confidence in our application’s core logic, making it more resilient to future changes. In the final chapter, we’ll cover how to deploy our Rust CLI application for general use.