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