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 filesFor 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.