Testing Rust CLI Applications with assert_cmd and predicates

Testing Rust CLI Applications with assert_cmd and predicates

Rust CLI applications have a unique testing challenge: you can unit test the business logic, but testing the actual binary — argument parsing, output formatting, exit codes — requires running the compiled program. assert_cmd is the standard crate for this.

This guide covers end-to-end CLI testing with assert_cmd and predicates, testing argument parsing with clap, and CI setup.

What assert_cmd Does

assert_cmd runs your binary as a subprocess and gives you a fluent API to assert on the output:

use assert_cmd::Command;

#[test]
fn test_help_flag() {
    Command::cargo_bin("myapp")
        .unwrap()
        .arg("--help")
        .assert()
        .success()
        .stdout(predicates::str::contains("Usage:"));
}

It automatically finds your compiled binary using cargo_bin, so you don't need to hardcode paths.

Setup

# Cargo.toml
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"

Testing Exit Codes

use assert_cmd::Command;

#[test]
fn test_exits_zero_on_success() {
    Command::cargo_bin("myapp")
        .unwrap()
        .arg("process")
        .arg("--input=tests/fixtures/valid.txt")
        .assert()
        .success();  // exit code 0
}

#[test]
fn test_exits_nonzero_on_invalid_input() {
    Command::cargo_bin("myapp")
        .unwrap()
        .arg("process")
        .arg("--input=nonexistent.txt")
        .assert()
        .failure();  // non-zero exit code
}

#[test]
fn test_exits_with_specific_code() {
    Command::cargo_bin("myapp")
        .unwrap()
        .arg("process")
        .arg("--input=nonexistent.txt")
        .assert()
        .code(1);  // specific exit code
}

Testing Output

use assert_cmd::Command;
use predicates::prelude::*;

#[test]
fn test_stdout_contains_expected_output() {
    Command::cargo_bin("myapp")
        .unwrap()
        .arg("greet")
        .arg("--name=Alice")
        .assert()
        .success()
        .stdout(predicate::str::contains("Hello, Alice!"));
}

#[test]
fn test_stdout_exact_match() {
    Command::cargo_bin("myapp")
        .unwrap()
        .arg("version")
        .assert()
        .stdout("myapp 1.0.0\n");
}

#[test]
fn test_stderr_on_error() {
    Command::cargo_bin("myapp")
        .unwrap()
        .arg("process")
        .arg("--input=nonexistent.txt")
        .assert()
        .failure()
        .stderr(predicate::str::contains("Error: file not found"));
}

#[test]
fn test_no_output_when_silent() {
    Command::cargo_bin("myapp")
        .unwrap()
        .arg("--quiet")
        .arg("process")
        .assert()
        .success()
        .stdout(predicate::str::is_empty());
}

Testing with Stdin

#[test]
fn test_reads_from_stdin() {
    Command::cargo_bin("myapp")
        .unwrap()
        .arg("process")
        .write_stdin("line 1\nline 2\nline 3\n")
        .assert()
        .success()
        .stdout(predicate::str::contains("3 lines processed"));
}

#[test]
fn test_handles_empty_stdin() {
    Command::cargo_bin("myapp")
        .unwrap()
        .arg("process")
        .write_stdin("")
        .assert()
        .success()
        .stdout(predicate::str::contains("0 lines processed"));
}

Testing with Files

use tempfile::NamedTempFile;
use std::io::Write;

#[test]
fn test_processes_file_correctly() {
    let mut input_file = NamedTempFile::new().unwrap();
    writeln!(input_file, "hello world").unwrap();
    writeln!(input_file, "foo bar").unwrap();
    
    Command::cargo_bin("myapp")
        .unwrap()
        .arg("count-words")
        .arg(input_file.path())
        .assert()
        .success()
        .stdout("4\n");  // 4 words total
}

#[test]
fn test_writes_output_file() {
    let input = NamedTempFile::new().unwrap();
    let output_dir = tempfile::tempdir().unwrap();
    let output_path = output_dir.path().join("result.txt");
    
    Command::cargo_bin("myapp")
        .unwrap()
        .arg("transform")
        .arg("--input")
        .arg(input.path())
        .arg("--output")
        .arg(&output_path)
        .assert()
        .success();
    
    assert!(output_path.exists());
    let content = std::fs::read_to_string(&output_path).unwrap();
    assert!(content.contains("transformed"));
}

Testing Environment Variables

#[test]
fn test_reads_config_from_env() {
    Command::cargo_bin("myapp")
        .unwrap()
        .env("MYAPP_LOG_LEVEL", "debug")
        .env("MYAPP_API_URL", "http://localhost:8080")
        .arg("start")
        .assert()
        .success();
}

#[test]
fn test_fails_without_required_env_var() {
    Command::cargo_bin("myapp")
        .unwrap()
        .env_remove("MYAPP_API_KEY")  // Ensure it's not set
        .arg("upload")
        .assert()
        .failure()
        .stderr(predicate::str::contains("MYAPP_API_KEY must be set"));
}

Using predicates for Complex Assertions

use predicates::prelude::*;

#[test]
fn test_output_matches_regex() {
    Command::cargo_bin("myapp")
        .unwrap()
        .arg("status")
        .assert()
        .success()
        .stdout(predicate::str::is_match(r"Version: \d+\.\d+\.\d+").unwrap());
}

#[test]
fn test_output_line_count() {
    Command::cargo_bin("myapp")
        .unwrap()
        .arg("list")
        .assert()
        .success()
        .stdout(predicate::function(|stdout: &[u8]| {
            let text = std::str::from_utf8(stdout).unwrap();
            text.lines().count() == 5
        }));
}

#[test]
fn test_json_output() {
    let output = Command::cargo_bin("myapp")
        .unwrap()
        .arg("--format=json")
        .arg("status")
        .output()
        .unwrap();
    
    let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
    assert_eq!(json["status"], "ok");
}

Testing Subcommands

#[test]
fn test_subcommand_help() {
    let commands = ["init", "build", "test", "deploy"];
    
    for cmd in &commands {
        Command::cargo_bin("myapp")
            .unwrap()
            .arg(cmd)
            .arg("--help")
            .assert()
            .success();
    }
}

#[test]
fn test_unknown_subcommand_fails() {
    Command::cargo_bin("myapp")
        .unwrap()
        .arg("nonexistent-command")
        .assert()
        .failure()
        .stderr(predicate::str::contains("error: unrecognized subcommand"));
}

Testing Argument Validation

#[test]
fn test_required_argument_missing() {
    Command::cargo_bin("myapp")
        .unwrap()
        .arg("process")
        // Missing required --input argument
        .assert()
        .failure()
        .stderr(predicate::str::contains("required"));
}

#[test]
fn test_mutually_exclusive_args() {
    Command::cargo_bin("myapp")
        .unwrap()
        .arg("process")
        .arg("--verbose")
        .arg("--quiet")  // Can't use both
        .assert()
        .failure();
}

#[test]
fn test_invalid_argument_value() {
    Command::cargo_bin("myapp")
        .unwrap()
        .arg("process")
        .arg("--count=abc")  // Should be a number
        .assert()
        .failure()
        .stderr(predicate::str::contains("invalid"));
}

CI Configuration

# .github/workflows/test.yml
- name: Run CLI tests
  run: cargo test --test cli_tests -- --test-threads=1
  # --test-threads=1 if tests write to shared files

For integration tests in a separate file:

tests/
  cli_tests.rs     # assert_cmd tests (integration tests)
src/
  main.rs
  lib.rs
  commands/
// tests/cli_tests.rs — integration test file
use assert_cmd::Command;
use predicates::prelude::*;

#[test]
fn cli_integration_test() {
    Command::cargo_bin("myapp")
        .unwrap()
        .assert()
        .success();
}

Conclusion

assert_cmd makes Rust CLI testing practical: you test the actual binary, not a mocked version, so you catch argument parsing bugs, exit code issues, and output formatting problems that unit tests miss. Use predicates for expressive output assertions and tempfile for isolated file-based tests. Add these integration tests to your CI pipeline alongside unit tests for complete CLI coverage.

Read more

Start now free