How to Use the assert_cmd and predicates Crates for CLI Testing

Cli
Use assert_cmd to run your binary in tests and verify exit codes or output without manual shell scripting.

The CLI contract

You just finished building my-cli. You run cargo run -- --version and it prints 0.1.0. You feel good. You refactor the argument parser to support a new flag, run the tool again, and suddenly --version panics because you accidentally shadowed a variable. You didn't notice until you typed the command manually.

Manual testing is slow and forgetful. You need a way to run your binary programmatically, check the exit code, and verify the output matches expectations every time you change code. That's where assert_cmd and predicates come in. They turn your CLI into a testable unit by spawning the actual binary and letting you assert on its behavior.

Manual testing is a memory game. Tests are the score.

Spawning the binary

Testing a CLI is different from testing a library function. A function returns a value you can inspect. A CLI exits with a code and writes to stdout or stderr. You can't just call a function and check the return value. You have to run the process.

assert_cmd bridges the gap. It spawns your compiled binary as a subprocess, captures the streams, and gives you a fluent API to assert on the result. It handles the plumbing of starting the process, waiting for it to finish, and collecting the output. predicates adds the power to match complex patterns in that output, like regex or substring checks, without writing boilerplate string comparisons.

Together, they let you write tests that read like specifications. You describe what the command should do, and the crates verify it. If the binary misbehaves, the test fails with a clear message showing exactly what went wrong.

The subprocess is the truth. Everything else is speculation.

Minimal setup

Add assert_cmd and predicates to your dev-dependencies. These crates are only needed for testing, not for the production binary.

[dev-dependencies]
assert_cmd = "2"
predicates = "3"

Write a test that spawns the binary and checks the exit code. Use Command::cargo_bin to find the binary path automatically. This avoids hardcoding paths that change between debug and release builds.

use assert_cmd::Command;

/// Verifies the binary runs and exits successfully with --version.
#[test]
fn test_version_flag() {
    // Spawns the binary built by cargo. Unwrap fails the test if the binary is missing.
    let mut cmd = Command::cargo_bin("my-cli").unwrap();

    // Adds the argument and executes the command.
    // success() asserts the exit code is 0.
    cmd.arg("--version").assert().success();
}

Convention aside: Command::cargo_bin is the standard way to find binaries in tests. It queries Cargo for the path to the binary in the target directory. If you use Command::new("my-cli"), the test relies on the binary being in your system PATH, which breaks in CI environments. Stick to cargo_bin.

Add the dependency. Write the test. Sleep better.

How the chain works

When you run cargo test, Cargo builds your binary in test mode. Command::cargo_bin("my-cli") asks Cargo for the path to that binary. It returns a Result<PathBuf>. You unwrap it because if the binary doesn't exist, the test setup is broken, and the test should fail immediately.

The Command struct holds the configuration for the subprocess. Calling .arg("--version") queues the argument. The command hasn't run yet. This builder pattern lets you construct the command step by step.

Calling .assert() executes the command. It waits for the process to finish and captures stdout and stderr. The method returns an Assert object that lets you chain assertions. .success() checks that the exit code is zero. If the exit code is non-zero, the test panics with a message showing the command, the exit code, and the captured output.

If you forget to declare cmd as mutable, the compiler rejects this with E0596 (cannot borrow as mutable). Methods like .arg() take &mut self because they modify the command configuration. Always use let mut cmd = ....

If cargo_bin fails, the test environment is broken. Fail fast.

Matching output with predicates

Checking the exit code is a good start. You also need to verify the output. predicates provides a rich set of matchers for strings, paths, and other types. It integrates with assert_cmd to let you assert on stdout and stderr directly.

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

/// Verifies error handling when a required file is missing.
#[test]
fn test_missing_file_error() {
    let mut cmd = Command::cargo_bin("my-cli").unwrap();

    cmd
        .arg("process")
        .arg("--file")
        .arg("missing.txt")
        .assert()
        // Asserts the command failed (exit code != 0).
        .failure()
        // Checks stderr contains the expected error substring.
        .stderr(predicate::str::contains("file not found"));
}

The predicates::prelude::* import brings in the common predicates and traits. This is the community convention. It avoids verbose paths like predicates::str::contains.

The .failure() assertion checks that the exit code is non-zero. This is important for error cases. You want to ensure the binary exits with an error code when something goes wrong, not just that it prints an error message.

The .stderr(...) assertion checks the captured stderr output. predicate::str::contains creates a predicate that matches if the string contains the substring. If the assertion fails, the test output shows the full stderr content, making it easy to debug.

Exact string matching is brittle. Patterns survive refactoring.

Feeding input and environment

CLIs often read from stdin or depend on environment variables. assert_cmd supports both. Use .write_stdin() to pipe data into the process. Use .env() to set environment variables.

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

/// Verifies the binary reads JSON from stdin and validates it.
#[test]
fn test_stdin_json_input() {
    let mut cmd = Command::cargo_bin("my-cli").unwrap();

    // Pipes a JSON string into stdin.
    cmd
        .write_stdin(r#"{"name": "test", "value": 42}"#)
        .arg("validate")
        .assert()
        .success()
        // Checks stdout starts with the expected prefix.
        .stdout(predicate::str::starts_with("Valid JSON"));
}

/// Verifies behavior when a config env var is set.
#[test]
fn test_env_config_override() {
    let mut cmd = Command::cargo_bin("my-cli").unwrap();

    cmd
        .env("MY_CLI_DEBUG", "1")
        .arg("status")
        .assert()
        .success()
        .stderr(predicate::str::contains("Debug mode enabled"));
}

write_stdin accepts anything that implements AsRef<[u8]>. You can pass a &str, a String, or a Vec<u8>. The data is written to the subprocess's stdin before the command starts.

env sets an environment variable for the subprocess. It doesn't affect the test process itself. This isolation is crucial. Tests run in parallel, and modifying global environment variables would cause race conditions. assert_cmd ensures each test gets its own clean environment.

Your CLI lives in an environment. Test the environment too.

Pitfalls and compiler friction

Testing CLIs introduces a few unique challenges. The compiler catches some, but others require careful attention.

If you pass a String to a predicate that expects &str, you might get E0308 (mismatched types). Predicates often use trait bounds like AsRef<str>. If the types don't align, the compiler will complain. Use .as_str() or pass a string literal to resolve this.

// This might fail with E0308 if the predicate expects &str.
// .stdout(predicate::str::contains(my_string));

// Fix: convert to &str explicitly.
.stdout(predicate::str::contains(my_string.as_str()));

Output buffering can cause tests to hang or see empty output. If your binary buffers stdout, the test might wait for the buffer to flush, which never happens if the process exits abruptly. Ensure your binary flushes output before exiting. Most Rust CLIs using println! flush automatically on newline, but custom writers might not.

assert_cmd has a timeout mechanism. If the subprocess hangs, the test will eventually fail with a timeout error. This is helpful for catching infinite loops, but it can make debugging flaky tests harder. Check your binary's logic if tests timeout intermittently.

The compiler protects you from typos. The test protects you from logic errors.

When to use what

Choosing the right tool depends on what you're testing. Use the right abstraction for the job.

Use assert_cmd when you need to test the full CLI experience, including argument parsing, exit codes, and stream output. Use predicates when your assertions require pattern matching, like checking for a substring, a regex, or a JSON structure in the output. Use std::process::Command when you are writing a library that spawns processes and need to test the spawning logic itself, or when you want to avoid external dependencies for simple checks. Use unit tests for your internal functions when you want fast feedback and don't need to verify the binary interface.

Test the interface. Trust the implementation.

Where to go next