How to Test Command-Line Applications in Rust (assert_cmd)

Test Rust CLI apps by spawning the binary with assert_cmd and asserting exit codes and output.

Testing CLI tools without the boilerplate

You built a command-line tool. It works perfectly when you type cargo run -- search query file.txt in your terminal. You feel confident. Then you decide to add a regression test. You open your test file and reach for std::process::Command. Suddenly you are wrestling with Output, parsing String from bytes, checking exit codes with nested if let blocks, and writing helper functions to compare output. Your test file is now 40 lines of plumbing for a single assertion. You just wanted to check if the output contains "error" and if the process succeeded.

Writing that subprocess boilerplate every time is tedious. It obscures the actual test logic. It makes tests harder to read and maintain. The assert_cmd crate solves this by wrapping subprocess execution in a fluent API designed for testing. It handles spawning, capturing, and assertion chaining so you can focus on the behavior you care about.

The QA robot analogy

Think of assert_cmd as a QA robot that runs your binary for you. You tell the robot what arguments to pass, and you hand it a checklist of expectations. The robot spawns the process, captures all output, waits for completion, checks the list, and reports pass or fail. You do not manage the robot's internals. You define the acceptance criteria.

The crate works in two parts. assert_cmd manages the process lifecycle. It resolves the binary path, sets arguments, spawns the subprocess, and captures stdout and stderr. predicates provides the matching logic. It lets you assert that output contains a substring, matches a regex, equals a specific string, or satisfies a custom condition. The two crates are separate because predicates is a general-purpose assertion library used across many testing contexts. assert_cmd depends on it for the heavy lifting of output verification.

Minimal example

Start with a simple test that verifies a successful run and checks stdout. Add assert_cmd and predicates to your dev-dependencies. They belong in dev-dependencies because they are only needed for testing, not for the production binary.

[dev-dependencies]
assert_cmd = "2.0"
predicates = "3.0"

Write the test using cargo_bin to locate the binary. This method resolves the path to the binary built by the test harness automatically. It avoids hardcoding paths or worrying about target directories.

use assert_cmd::Command;

#[test]
fn test_search_success() {
    // cargo_bin finds the binary named "my_cli" in the current package.
    // It returns a Result, so unwrap to panic the test if the binary is missing.
    let mut cmd = Command::cargo_bin("my_cli").unwrap();

    cmd.arg("search")
       .arg("pattern")
       .assert()
       // Chain assertions fluently.
       // success() checks that the exit code is 0.
       .success()
       // stdout() checks the captured output against a predicate.
       .stdout(predicates::str::contains("found match"));
}

Convention aside: use predicates::str::contains for substring checks. The predicates crate exports a prelude module that re-exports common items. Many projects add use predicates::prelude::*; to reduce import verbosity. Both styles compile. The explicit path is clearer for newcomers; the prelude is faster to type. Pick one and stick with it across your project.

Keep your assertions close to the command. Read the test like a usage example.

What happens under the hood

When you call Command::cargo_bin, the crate looks up the binary name in your Cargo.toml and constructs the path to the executable in the target directory. This path is relative to the test runner, so it works regardless of where you invoke cargo test.

Calling .arg() appends arguments to the command line. You can chain multiple .arg() calls. The crate does not execute the process yet. It builds the command configuration.

Calling .assert() triggers the execution. The crate spawns the subprocess, redirects stdout and stderr to pipes, and waits for the process to finish. It captures all output into memory. The method returns an Assert struct that holds the exit code and captured streams. This struct allows you to chain multiple assertions against the same execution result.

Calling .success() checks the exit code. It asserts that the code is zero. If the process exits with a non-zero code, the test fails immediately with a message showing the exit code and the captured output.

Calling .stdout() applies a predicate to the captured stdout. The predicate evaluates the string. If it returns false, the test fails with a detailed message showing the expected condition and the actual output.

If you try to reuse the Command instance after calling .assert(), the compiler rejects you with E0382 (use of moved value). The assert method consumes the command because it spawns the process. Create a new Command for each test case.

Trust the capture mechanism. The test sees exactly what the pipe delivers, not what your code thinks it printed.

Realistic scenarios

CLI tools often depend on environment variables, read from stdin, or write to stderr. assert_cmd supports all of these patterns. Use .env() to set environment variables for the subprocess. This keeps tests isolated from the developer's shell configuration. Use .write_stdin() to pipe data into the process. Use .stderr() to assert on error output.

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

#[test]
fn test_env_var_override() {
    let mut cmd = Command::cargo_bin("my_cli").unwrap();

    // Set environment variable for the subprocess only.
    // This keeps tests isolated from the developer's shell.
    cmd.env("DEBUG_MODE", "true")
       .arg("status")
       .assert()
       .success()
       .stdout(predicate::str::contains("Debug enabled"));
}

#[test]
fn test_missing_file_error() {
    let mut cmd = Command::cargo_bin("my_cli").unwrap();

    cmd.arg("search")
       .arg("pattern")
       .arg("nonexistent.txt")
       .assert()
       // Expect a non-zero exit code.
       .failure()
       // Check stderr for the error message.
       .stderr(predicate::str::contains("file not found"));
}

#[test]
fn test_stdin_input() {
    let mut cmd = Command::cargo_bin("my_cli").unwrap();

    // Pipe data into stdin.
    // The process reads until EOF.
    cmd.write_stdin("input line 1\ninput line 2\n")
       .arg("--from-stdin")
       .assert()
       .success()
       .stdout(predicate::str::contains("processed 2 lines"));
}

Convention aside: predicate::str::contains uses the predicate module from the prelude. If you see E0433 (could not find predicate), check your imports. The full path is predicates::str::contains. The prelude re-exports it as predicate::str::contains. This naming quirk trips up many developers. Add use predicates::prelude::*; to avoid the issue.

Test the failure paths. A CLI that crashes silently is worse than one that errors loudly.

Pitfalls and compiler errors

Buffering is the most common trap. If your binary buffers stdout, the test might see empty output even though the program printed text. The process finishes, but the buffer has not flushed to the pipe. Force your CLI to flush stdout, or use a flag like --no-buffer in tests. Do not rely on the OS to flush for you in tests.

Binary name mismatches cause cargo_bin to fail. If your Cargo.toml defines a binary with a different name than the package, cargo_bin("package_name") returns an error. Use the exact binary name from the [[bin]] section. If you have multiple binaries, specify the correct name in each test.

Version drift between assert_cmd and predicates causes compile errors. assert_cmd 2.0 requires predicates 3.0. Mixing versions results in trait bound errors. E0277 (trait bound not satisfied) often appears when the predicate types do not match the expected trait. Check your Cargo.toml to ensure compatible versions.

Flaky tests can occur if your CLI spawns background threads. assert_cmd waits for the main process to exit. Background threads may continue running after the test finishes. This can cause resource leaks or race conditions in CI. Ensure your CLI joins all threads before exiting, or use a shutdown mechanism in tests.

Flush your buffers. The test harness captures what the pipe delivers, not what your code thinks it printed.

When to use assert_cmd

Use assert_cmd when you need to test the full binary behavior, including argument parsing, exit codes, and output formatting. Use assert_cmd when you want tests that read like usage examples and verify the user-facing contract. Use assert_cmd when you need to test environment variables, stdin input, and stderr output without writing plumbing code.

Use unit tests on your internal functions when you want to test logic in isolation without the overhead of spawning a subprocess. Unit tests run faster and provide more precise failure locations for internal bugs.

Use std::process::Command directly when you need fine-grained control over the process environment, such as manipulating file descriptors, handling interactive input streams, or integrating with custom test harnesses. Direct control is necessary for advanced scenarios but adds boilerplate.

Reach for assert_cmd over manual Command usage in almost every CLI test scenario. The boilerplate reduction and better failure messages pay for themselves immediately.

Trust assert_cmd for the heavy lifting. Your time is better spent writing assertions than parsing strings.

Where to go next