Integration Testing

Testing Binary and Library Crates

In Rust, integration test crates in tests/ can only import functions and types (use my_crate::...) from a library target (src/lib.rs)—they cannot directly import items defined inside a binary target (src/main.rs or src/bin/*.rs). To make an application testable via integration tests, Rust projects split the package into a core src/lib.rs library and a thin src/main.rs binary wrapper, and test the compiled executable itself via env!("CARGO_BIN_EXE_<name>").

Recommended Dual-Target Package Architecture:

my_cli/
├── Cargo.toml
├── src/
│   ├── lib.rs       <-- Contains all business logic, CLI parsing, and errors
│   └── main.rs      <-- Thin 10-line wrapper: calls `my_cli::run()`
└── tests/
    ├── api_test.rs  <-- Imports `my_cli::*` directly in-process
    └── cli_test.rs  <-- Spawns `env!("CARGO_BIN_EXE_my_cli")` as a subprocess

The Thin main.rs + Core lib.rs Pattern

By moving all application logic—including argument parsing and output formatting (parameterized over impl Write)—into src/lib.rs, src/main.rs shrinks to a few lines that pass std::env::args() and std::io::stdout() into the library:

// In `src/lib.rs`:
use std::io::{self, Write};

pub fn run_greeter<I, W>(mut args: I, mut out: W) -> Result<(), String>
where
    I: Iterator<Item = String>,
    W: Write,
{
    let _program = args.next(); // Skip argv[0]
    let name = args.next().unwrap_or_else(|| "World".to_string());
    if name.starts_with("--") {
        return Err(format!("unknown flag: {name}"));
    }
    writeln!(out, "Hello, {name}!").map_err(|e: io::Error| e.to_string())
}

// In `src/main.rs` (or an integration test calling `run_greeter` in-memory):
fn main() {
    let simulated_args = vec!["my_cli".to_string(), "Rustacean".to_string()];
    let mut captured_stdout = Vec::new();

    run_greeter(simulated_args.into_iter(), &mut captured_stdout).unwrap();
    let text = String::from_utf8(captured_stdout).unwrap();
    assert_eq!(text, "Hello, Rustacean!\n");
    print!("{text}");
}
Hello, Rustacean!

Spawning the Compiled Binary in Integration Tests (CARGO_BIN_EXE_<name>)

When compiling integration tests in tests/ (and benchmarks in benches/), Cargo automatically builds all binary targets in the package and sets the compile-time environment variable CARGO_BIN_EXE_<binary_name> to the exact absolute path of each compiled executable.

This allows an integration test to spawn the real binary with std::process::Command::new(env!("CARGO_BIN_EXE_my_cli")) to verify exit codes, stdout, and stderr end to end:

use std::process::Command;

// Inside `tests/cli_test.rs`, you would write:
// const BIN_PATH: &str = env!("CARGO_BIN_EXE_my_cli");

fn verify_cli_binary(bin_path: &str) {
    let output = Command::new(bin_path)
        .arg("--version")
        .output()
        .expect("failed to execute binary");

    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    println!("Verified binary exit 0; stdout starts with: {}", &stdout[..5]);
}

fn main() {
    // Demonstrate using `rustc` as the target executable
    verify_cli_binary("rustc");
}
Verified binary exit 0; stdout starts with: rustc
Testing Approach Speed What It Verifies
Calling my_cli::run(...) in tests/ Microseconds (in-process function call) Business logic, flags, error values, and formatted output streams.
Spawning env!("CARGO_BIN_EXE_my_cli") Milliseconds (OS process spawn) Actual fn main() wiring, OS exit codes (0 vs non-zero), signal handling, and stdin/stdout pipes.