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

CLI Testing

🔧 HOW-TO | 📚 REFERENCE | Test command-line interfaces with golden files

Test command-line interfaces using golden files (.trycmd) to verify commands work correctly.

Why CLI Testing?

CLIs are complex because they involve:

  • Argument parsing
  • Environment variables
  • Output formatting
  • Exit codes
  • Error messages

Golden file testing captures all of this:

#![allow(unused)]
fn main() {
// ✅ Golden file testing
test!(test_cli, {
    let output = run_command("myapp", vec!["list", "--verbose"]);
    assert_matches!(output, "cli_list_verbose");  // .trycmd file
});
}

The .trycmd file contains:

$ myapp list --verbose
stdout: Item 1
        Item 2
exit-code: 0

Basic CLI Testing

Creating a Test Command

#![allow(unused)]
fn main() {
use chicago_tdd_tools::cli::*;

test!(test_cli_basic, {
    let output = CliTest::new("myapp", vec!["help"])
        .run()?;

    assert!(output.contains("Usage:"));
    assert!(output.contains("Options:"));
});
}

Testing with Arguments

#![allow(unused)]
fn main() {
test!(test_cli_with_args, {
    let output = CliTest::new("myapp", vec![
        "process",
        "--input", "data.txt",
        "--output", "result.txt",
        "--verbose"
    ]).run()?;

    assert!(output.contains("Processing"));
});
}

Testing Environment Variables

#![allow(unused)]
fn main() {
test!(test_cli_with_env, {
    let output = CliTest::new("myapp", vec!["list"])
        .env("LOG_LEVEL", "DEBUG")
        .env("TIMEOUT", "30")
        .run()?;

    assert!(output.contains("DEBUG"));
});
}

Golden File Format (.trycmd)

Golden files store expected output:

$ myapp list
Item 1
Item 2
Item 3

$ myapp list --filter active
Item 1
Item 3

Command Line

$ myapp [args]

Output

stdout:
Actual command output
Goes here

stderr:
Error output if applicable

Exit Code

exit-code: 0  (Success)
exit-code: 1  (Failure)

Real-World Example: File Tool

#![allow(unused)]
fn main() {
test!(test_file_commands, {
    // List files
    let list_output = CliTest::new("filetool", vec!["list"])
        .run()?;
    assert!(list_output.contains("data.txt"));

    // Copy file
    let copy_output = CliTest::new("filetool", vec![
        "copy",
        "source.txt",
        "dest.txt"
    ]).run()?;
    assert!(copy_output.contains("Copied"));

    // Delete file
    let del_output = CliTest::new("filetool", vec![
        "delete",
        "old.txt"
    ]).run()?;
    assert!(del_output.contains("Deleted"));
});
}

Real-World Example: Configuration Tool

#![allow(unused)]
fn main() {
test!(test_config_commands, {
    // Get config
    let output = CliTest::new("config", vec!["get", "database.host"])
        .env("CONFIG_PATH", "./config.toml")
        .run()?;
    assert!(output.contains("localhost"));

    // Set config
    let output = CliTest::new("config", vec!["set", "database.port", "5433"])
        .env("CONFIG_PATH", "./config.toml")
        .run()?;
    assert!(output.contains("Updated"));

    // List all config
    let output = CliTest::new("config", vec!["list"])
        .run()?;
    assert!(output.contains("database.host"));
});
}

Error Testing

Command Failures

#![allow(unused)]
fn main() {
test!(test_cli_errors, {
    // Wrong arguments
    let output = CliTest::new("myapp", vec!["invalid-command"])
        .run();

    assert!(output.is_err());  // Command failed
});
}

Exit Codes

#![allow(unused)]
fn main() {
test!(test_exit_codes, {
    // Success
    let result = CliTest::new("myapp", vec!["list"]).run()?;
    assert_eq!(result.exit_code, 0);

    // Failure
    let result = CliTest::new("myapp", vec!["error"]).run()?;
    assert_ne!(result.exit_code, 0);
});
}

Assertion Helpers

Contains

#![allow(unused)]
fn main() {
test!(test_cli_contains, {
    let output = CliTest::new("myapp", vec!["help"]).run()?;
    assert!(output.contains("Usage:"));
});
}

Matches Pattern

#![allow(unused)]
fn main() {
test!(test_cli_pattern, {
    let output = CliTest::new("myapp", vec!["version"]).run()?;
    assert!(output.contains("v1."));  // Matches v1.0, v1.1, etc.
});
}

Snapshot

#![allow(unused)]
fn main() {
test!(test_cli_snapshot, {
    let output = CliTest::new("myapp", vec!["help"]).run()?;
    assert_matches!(output, "myapp_help");  // Golden file
});
}

Comprehensive CLI Test

#![allow(unused)]
fn main() {
test!(test_cli_comprehensive, {
    // Test 1: Help works
    let help = CliTest::new("mytool", vec!["help"]).run()?;
    assert!(help.contains("Usage:"));

    // Test 2: List works
    let list = CliTest::new("mytool", vec!["list"]).run()?;
    assert!(list.contains("Item"));

    // Test 3: Filter works
    let filtered = CliTest::new("mytool", vec![
        "list",
        "--filter", "active"
    ]).run()?;
    assert!(filtered.contains("Item 1"));

    // Test 4: Sort works
    let sorted = CliTest::new("mytool", vec![
        "list",
        "--sort", "name"
    ]).run()?;
    let lines: Vec<_> = sorted.lines().collect();
    assert!(lines.len() >= 2);

    // Test 5: Output format
    let json = CliTest::new("mytool", vec![
        "list",
        "--format", "json"
    ]).run()?;
    assert!(json.contains("{"));
});
}

Best Practices

Do:

  • Test all major commands
  • Test error cases
  • Test environment variables
  • Test output format
  • Use golden files for complex output

Don't:

  • Hard-code full output (use snapshots)
  • Test implementation details
  • Ignore error exit codes
  • Use shell pipes in tests
  • Test external commands

When to Use CLI Testing

Use for:

  • CLI applications
  • Command subcommands
  • Argument parsing
  • Output formatting
  • Error messages

Don't use for:

  • Library functions (use unit tests)
  • Web services (use integration tests)
  • Complex pipelines (too fragile)

Combining with Other Techniques

CLI + Snapshots

#![allow(unused)]
fn main() {
test!(test_cli_snapshot, {
    let output = CliTest::new("myapp", vec!["help"]).run()?;
    assert_matches!(output, "help_output");  // Snapshot
});
}

CLI + Properties

#![allow(unused)]
fn main() {
test!(test_cli_properties, {
    let strategy = ProptestStrategy::new().with_cases(100);

    strategy.test(any::<String>(), |cmd| {
        let output = CliTest::new("myapp", vec![&cmd]).run();
        // Property: Command doesn't crash
        true  // If crashes, test fails
    });
});
}

Troubleshooting

Test Fails with Different Output

Check for:

  • Timestamps (use [TIMESTAMP])
  • UUIDs (use [UUID])
  • Paths (use relative paths)

Command Not Found

Ensure binary is built:

cargo build --bin myapp
# Then tests can run it

Flaky Tests

Normalize output:

#![allow(unused)]
fn main() {
let output = CliTest::new("myapp", vec!["status"]).run()?;
let normalized = output.replace("2024-11-15", "[DATE]");
assert!(normalized.contains("Started on [DATE]"));
}

Next Steps

Learn concurrency testing: Concurrency Testing


Summary

CLI testing:

  • ✅ Tests command-line interfaces
  • ✅ Uses golden files
  • ✅ Detects output changes
  • ✅ Verifies exit codes

Perfect for CLI applications and scripts.