Cargo & Testing
Cargo: The Rust Build System and Package Manager
Section titled “Cargo: The Rust Build System and Package Manager”Cargo is Rust’s official build system and package manager. Every Rust project is a crate, and Cargo manages its dependencies, builds, tests, and documentation.
Essential Cargo Commands
Section titled “Essential Cargo Commands”# Create a new binary projectcargo new my_projectcd my_project
# Create a new library cratecargo new my_lib --lib
# Build in debug mode (fast compile, unoptimized)cargo build
# Build in release mode (slow compile, optimized)cargo build --release
# Build and run in one stepcargo run
# Run with argumentscargo run -- arg1 arg2
# Check for compile errors without producing a binarycargo check
# Add a dependencycargo add serde --features derive
# Update dependencies to latest compatible versionscargo update
# Format codecargo fmt
# Run the lintercargo clippyProject Structure
Section titled “Project Structure”flowchart TD root["my_project/"] toml["Cargo.toml — manifest: name, version, dependencies"] lock["Cargo.lock — exact dependency versions (commit for binaries)"] src["src/"] main["main.rs — entry point for binary crates"] lib["lib.rs — entry point for library crates"] tests["tests/"] itest["integration_test.rs — integration tests (optional)"] root --> toml root --> lock root --> src root --> tests src --> main src --> lib tests --> itest
Cargo.toml Anatomy
Section titled “Cargo.toml Anatomy”[package]name = "my_project"version = "0.1.0"edition = "2024"
[dependencies]serde = { version = "1", features = ["derive"] }tokio = { version = "1", features = ["full"] }anyhow = "1"
[dev-dependencies]# only compiled for tests and benchmarkspretty_assertions = "1"Editions: what edition = "2024" means
Section titled “Editions: what edition = "2024" means”The edition key selects the language dialect your crate compiles under. Editions let Rust change syntax and idioms without splitting the ecosystem — crates on different editions link together fine, so every crate upgrades on its own schedule.
cargo new has produced edition = "2024" since Rust 1.85 (February 2025). What that edition changed, roughly in the order you will notice it:
| Change | What it means for you |
|---|---|
| Let chains | if let Some(x) = opt && x > 2 compiles — on the 2024 edition only |
if let temporary scope | Temporaries drop before the else branch, removing a class of lock deadlocks |
impl Trait lifetime capture | A returned impl Trait captures every in-scope lifetime |
unsafe extern | extern blocks must be written unsafe extern "C" |
gen reserved | gen became a keyword, held for future generator blocks |
Moving an existing crate is a two-step job, and Cargo does most of it:
# 1. rewrite the code so it means the same thing under both editionscargo fix --edition
# 2. bump edition = "2024" in Cargo.toml, then confirm behaviourcargo testMSRV-aware dependency resolution is a separate, opt-in Cargo mechanism — it comes from the rust-version field plus the resolver.incompatible-rust-versions config (default fallback since Cargo 1.84), and is independent of edition. With rust-version set, the default fallback behavior makes Cargo skip a dependency version whose own declared rust-version is newer than yours, falling back to an older compatible release instead; set resolver.incompatible-rust-versions = "deny" to make that situation a hard error rather than a silent downgrade.
Unit Testing with #[test]
Section titled “Unit Testing with #[test]”Rust has testing built into the language and Cargo — no extra test framework needed. Annotate a function with #[test] to mark it as a test.
fn add(a: i32, b: i32) -> i32 { a + b}
#[test]fn test_add() { assert_eq!(add(2, 3), 5); assert_eq!(add(-1, 1), 0); assert_ne!(add(2, 3), 6);}Run all tests with:
cargo testThe #[cfg(test)] Module Pattern
Section titled “The #[cfg(test)] Module Pattern”The idiomatic place for unit tests is inside the source file they test, inside a mod tests block gated by #[cfg(test)]. Cargo only compiles this module when running cargo test, keeping it out of production binaries.
pub fn multiply(a: i32, b: i32) -> i32 { a * b}
pub fn divide(a: f64, b: f64) -> Option<f64> { if b == 0.0 { None } else { Some(a / b) }}
#[cfg(test)]mod tests { use super::*; // bring parent module items into scope
#[test] fn test_multiply() { assert_eq!(multiply(3, 4), 12); assert_eq!(multiply(0, 100), 0); assert_eq!(multiply(-2, 5), -10); }
#[test] fn test_divide_by_zero() { assert_eq!(divide(10.0, 0.0), None); }
#[test] fn test_divide_normal() { let result = divide(10.0, 4.0).unwrap(); assert!((result - 2.5).abs() < f64::EPSILON); }}Test Assertions
Section titled “Test Assertions”#[cfg(test)]mod tests { #[test] fn demonstrate_assertions() { // equality assert_eq!(2 + 2, 4); assert_ne!(2 + 2, 5);
// boolean assert!(4 > 3); assert!(!"hello".is_empty());
// with custom message assert_eq!(1 + 1, 2, "basic arithmetic must hold");
// panics // use #[should_panic] to assert a function panics }
#[test] #[should_panic(expected = "divide by zero")] fn test_panic() { let _ = 1 / std::hint::black_box(0); // this panics — test passes because we expect it }}A literal 1 / 0 does not even get this far: the constant-propagation pass proves the division always panics and refuses to compile it, since unconditional_panic is a deny-by-default lint. std::hint::black_box hides the 0 from that analysis so the panic happens at runtime instead, which is what #[should_panic] needs to test.
error: this operation will panic at runtime --> src/main.rs:23:17 |23 | let _ = 1 / 0; // this panics — test passes because we expect it | ^^^^^ attempt to divide `1_i32` by zero | = note: `#[deny(unconditional_panic)]` on by defaultWith the fix, cargo test compiles and the test passes:
running 2 teststest tests::demonstrate_assertions ... oktest tests::test_panic - should panic ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sWhat assert_eq! Prints When It Actually Fails
Section titled “What assert_eq! Prints When It Actually Fails”Every example so far has passed. Here is the real output when a test genuinely fails — the exact format matters because you will be staring at it under pressure someday:
// @expect-panicfn add(a: i32, b: i32) -> i32 { a + b}
#[cfg(test)]mod tests { use super::*;
#[test] fn test_add_wrong_on_purpose() { assert_eq!(add(2, 2), 5); }}running 1 testtest tests::test_add_wrong_on_purpose ... FAILED
failures:
---- tests::test_add_wrong_on_purpose stdout ----
thread 'tests::test_add_wrong_on_purpose' (<id>) panicked at src/main.rs:11:9:assertion `left == right` failed left: 4 right: 5note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures: tests::test_add_wrong_on_purpose
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sassert_eq! always labels its two sides left/right (matching the order you wrote them in the macro call), never “expected”/“actual” — a detail that is easy to misremember, and worth double-checking on the first read every time, since left here is add(2, 2) and right is your literal 5.
Why should_panic’s expected Argument Matters
Section titled “Why should_panic’s expected Argument Matters”#[should_panic] alone only checks that the test panics somehow — it says nothing about why. That gap lets a test pass while verifying nothing about the function it claims to test:
fn divide(a: i32, b: i32) -> i32 { if b == 0 { panic!("cannot divide by zero"); } a / b}
#[cfg(test)]mod tests { use super::*;
// BUG: expected is missing, so this test passes even though the panic // message has nothing to do with division — it "passes for the wrong reason". #[test] #[should_panic] fn test_divide_by_zero_no_expected() { let v: Vec<i32> = Vec::new(); let _ = v[0]; // panics with an out-of-bounds index, NOT "cannot divide by zero" let _ = divide(1, 0); // never reached }
// FIXED: expected pins down which panic message is acceptable. #[test] #[should_panic(expected = "cannot divide by zero")] fn test_divide_by_zero_expected() { divide(1, 0); }}running 2 teststest tests::test_divide_by_zero_expected - should panic ... oktest tests::test_divide_by_zero_no_expected - should panic ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sBoth tests report ok. test_divide_by_zero_no_expected never even reaches the call to divide — it panics on an out-of-bounds Vec index first — but #[should_panic] with no expected accepts any panic, so the test passes while testing nothing about divide at all. #[should_panic(expected = "cannot divide by zero")] closes that gap: the test only passes if the panic message actually contains that substring.
#[ignore] and --nocapture
Section titled “#[ignore] and --nocapture”Mark a slow or environment-dependent test with #[ignore] to skip it by default; run it explicitly with --ignored when you want to:
#[cfg(test)]mod tests { #[test] #[ignore] // skipped by default — run explicitly with `cargo test -- --ignored` fn slow_integration_check() { std::thread::sleep(std::time::Duration::from_secs(5)); assert!(true); }
#[test] fn fast_check() { assert_eq!(2 + 2, 4); }}running 2 teststest tests::fast_check ... oktest tests::slow_integration_check ... ignored
test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00sBy default, cargo test also captures every passing test’s stdout and discards it — you only see output from tests that fail. Pass -- --nocapture to see println! output from passing tests too, which is invaluable while writing a new test and checking it does what you expect before trusting the assertion alone.
Integration Tests
Section titled “Integration Tests”Files under tests/ are integration tests. They test your crate’s public API as an external consumer would.
// @skip-verify multi-file: integration testuse my_lib::multiply;
#[test]fn integration_multiply() { assert_eq!(multiply(6, 7), 42);}Run only integration tests:
cargo test --test integration_testRun a specific test by name:
cargo test test_divideDoc Tests: Examples That Are Also Tests
Section titled “Doc Tests: Examples That Are Also Tests”A /// doc comment’s code example is compiled and run by cargo test too — this is how the standard library keeps its documentation examples honest; if an example stops compiling or its assert_eq! starts failing, cargo test fails right along with it. Doc examples are usually written as a fenced block of three backticks inside the comment; the indented style below (four spaces, no backticks) is an older but still fully supported way to mark the same kind of runnable example, used here only so this example doesn’t need a code fence nested inside another code fence.
/// Adds two numbers together.////// # Examples////// use cargo_tests_scratch::add;/// assert_eq!(add(2, 3), 5);pub fn add(a: i32, b: i32) -> i32 { a + b}cargo test --doc Doc-tests cargo_tests_scratch
running 1 testtest src/lib.rs - add (line 5) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01sDoc tests run in a separate pass from unit and integration tests (you can see the distinct Doc-tests header), and each one compiles as its own tiny binary against your crate’s public API — the same boundary integration tests in tests/ exercise, which is why the example imports add with use cargo_tests_scratch::add; instead of relying on being inside the crate.
Testing Async Code with tokio
Section titled “Testing Async Code with tokio”For async functions, use #[tokio::test]:
// @skip-verify crate: tokio#[cfg(test)]mod tests { use super::*;
#[tokio::test] async fn test_async_fetch() { let result = fetch_data(1).await; assert!(!result.is_empty()); }}Add tokio to [dev-dependencies] to use #[tokio::test] without pulling it into your production binary if you do not need it there.
cargo clippy: Catching Smells rustc Doesn’t
Section titled “cargo clippy: Catching Smells rustc Doesn’t”rustc checks that your code is valid; clippy checks that it is idiomatic. Running it with a handful of extra lints enabled against a small file with several deliberate AI-style smells turns up real, specific warnings — not hypothetical ones:
// @skip-verify demo: illustrates multiple clippy lints together, not meant to runuse std::sync::Mutex;
async fn bad_pattern(shared: &Mutex<i32>) { let guard = shared.lock().unwrap(); tokio::time::sleep(std::time::Duration::from_millis(1)).await; // guard is still held here println!("{}", *guard);}
fn redundant_clone_example(s: &String) -> String { let t = s.clone(); t.clone() // clippy::redundant_clone}
fn needless_range_loop_example(v: &[i32]) -> i32 { let mut sum = 0; for i in 0..v.len() { sum += v[i]; // clippy::needless_range_loop } sum}
fn manual_map_example(opt: Option<i32>) -> Option<i32> { match opt { Some(x) => Some(x + 1), // clippy::manual_map None => None, }}
enum Shape { Circle(f64), Polygon([f64; 64]), // clippy::large_enum_variant}cargo clippy -- \ -W clippy::redundant_clone \ -W clippy::unwrap_used \ -W clippy::await_holding_lock \ -W clippy::needless_range_loop \ -W clippy::manual_map \ -W clippy::large_enum_variantwarning: this `MutexGuard` is held across an await point --> src/main.rs:4:9 |4 | let guard = shared.lock().unwrap(); | ^^^^^ | = help: consider using an async-aware `Mutex` type or ensuring the `MutexGuard` is dropped before calling `await`
warning: used `unwrap()` on a `Result` value --> src/main.rs:4:17 |4 | let guard = shared.lock().unwrap(); | ^^^^^^^^^^^^^^^^^^^^^^ | = note: if this value is an `Err`, it will panic
warning: redundant clone --> src/main.rs:11:6 |11 | t.clone() // clippy::redundant_clone | ^^^^^^^^ help: remove this |note: this value is dropped without further use
warning: the loop variable `i` is only used to index `v` --> src/main.rs:16:14 |16 | for i in 0..v.len() { | ^^^^^^^^^^ |help: consider using an iterator |16 - for i in 0..v.len() {16 + for <item> in &v { |
warning: manual implementation of `Option::map` --> src/main.rs:23:5 |23 | / match opt {24 | | Some(x) => Some(x + 1), // clippy::manual_map25 | | None => None,26 | | } | |_____^ help: try: `opt.map(|x| x + 1)`
warning: large size difference between variants --> src/main.rs:29:1 |29 | / enum Shape {30 | | Circle(f64), | | ----------- the second-largest variant contains at least 8 bytes31 | | Polygon([f64; 64]), // clippy::large_enum_variant | | ------------------ the largest variant contains at least 512 bytes32 | | } | |_^ the entire enum is at least 520 byteshelp: consider boxing the large fields or introducing indirection in some other way to reduce the total size of the enumSix distinct lints, six specific fixes — none of these are style nitpicks; each one names a real cost (a lock held too long, a clone that does nothing, an unwrap() that can panic, an enum that wastes space on every instance). clippy::unwrap_used and clippy::redundant_clone are not in clippy’s default warn set (they need the explicit -W, as shown) precisely because they are common enough in legitimate code that clippy doesn’t want to nag by default — but turning them on deliberately in CI for a codebase written or reviewed by AI is a reasonable, cheap tripwire.
cargo fmt --check
Section titled “cargo fmt --check”cargo fmt reformats your code to the project’s canonical style; cargo fmt --check (the CI-friendly variant) reports what it would change without touching any files, and exits non-zero if anything is not already formatted:
cargo fmt --checkDiff in /path/to/project/src/badfmt.rs:1:-pub fn messy( a:i32,b :i32)->i32{a+b}+pub fn messy(a: i32, b: i32) -> i32 {+ a + b+}Run this in CI (not cargo fmt itself, which would silently rewrite files) so a pull request with inconsistent formatting fails the build instead of drifting the codebase’s style over time.
cargo audit and cargo deny: Dependency Security
Section titled “cargo audit and cargo deny: Dependency Security”Neither ships with Cargo by default — both are separately installed (cargo install cargo-audit, cargo install cargo-deny) and then run as cargo audit / cargo deny check. cargo audit cross-references your Cargo.lock against the RustSec security advisory database and flags any dependency with a known vulnerability. cargo deny is broader: it can also enforce license allowlists, ban specific crates or crate sources, and flag duplicate versions of the same dependency in your dependency tree. Both are commonly wired into CI so a newly-disclosed vulnerability in a transitive dependency fails the build instead of shipping silently.
rust-toolchain.toml: Pinning the Compiler Version
Section titled “rust-toolchain.toml: Pinning the Compiler Version”A rust-toolchain.toml file at the project root pins the exact toolchain rustup uses for that project, so every contributor (and CI) builds with the same compiler without needing to remember to run rustup override set:
[toolchain]channel = "1.96.1"components = ["clippy", "rustfmt"]This is a different mechanism from the rust-version field in Cargo.toml covered above: rust-version documents (and lets the resolver reason about) the minimum supported version a published crate works with; rust-toolchain.toml pins the exact version a local checkout of this repository builds with, regardless of what else is installed on the machine.
Workspaces: Multiple Crates, One Cargo.lock
Section titled “Workspaces: Multiple Crates, One Cargo.lock”A workspace groups multiple crates under one top-level Cargo.toml and one shared Cargo.lock, so they always build against the same dependency versions:
# Cargo.toml at the workspace root[workspace]members = ["api", "core", "cli"]resolver = "3"Each member (api/, core/, cli/) keeps its own Cargo.toml with its own [dependencies], but cargo build/cargo test from the workspace root builds and tests all of them together, and cargo test -p core targets just one member.
cargo test --release: Overflow Checks Are Off
Section titled “cargo test --release: Overflow Checks Are Off”Debug builds enable arithmetic overflow checks by default; release builds do not, for performance. The same test can pass or fail depending entirely on which profile ran it:
// @skip-verify demo: shows debug-vs-release divergence, not a single runnable outcomepub fn will_overflow(x: u8) -> u8 { x + std::hint::black_box(10) // overflows once x > 245}
#[cfg(test)]mod tests { use super::*;
#[test] fn test_overflow() { println!("result: {}", will_overflow(250)); }}# cargo test --lib (debug profile — default)thread 'tests::test_overflow' (<id>) panicked at src/lib.rs:2:5:attempt to add with overflowtest tests::test_overflow ... FAILED
# cargo test --release --lib (release profile)result: 4test tests::test_overflow ... okIn debug, 250_u8 + 10 panics with “attempt to add with overflow” — a real bug is caught immediately. In release, the same arithmetic silently wraps (250 + 10 = 260, which wraps to 4 in a u8), and the test passes despite computing a wrong answer. This is exactly why cargo test --release deserves a moment’s suspicion when it makes a previously-failing test pass: a wrapped, silently-wrong number is a live production bug, not a fix. If wrapping is genuinely intended behavior, spell it out explicitly with wrapping_add instead of relying on the profile to decide for you.
Mental Model
Section titled “Mental Model”Cargo, rustc, and clippy divide labor cleanly: rustc (via cargo build/cargo check) answers “is this valid Rust” — memory safety, types, borrows. clippy answers “is this idiomatic Rust” — the same valid code, but flagged for patterns that are usually a mistake or an unnecessary cost. cargo test answers “does this do what I claim it does” — and that claim is only as strong as the assertions inside each test, which is why a #[should_panic] with no expected, or a test with no assert at all, can report ok while verifying nothing.
Trade-offs
Section titled “Trade-offs”Unit tests (#[cfg(test)] mod tests inside the source file) compile fast and can reach private items, but only test one module in isolation. Integration tests (tests/) compile slower (each file is its own crate) and can only reach public API, but catch wiring bugs unit tests structurally cannot see. Doc tests are slowest per-example (each doc example compiles as its own tiny binary) but pay for themselves by keeping documentation from silently rotting — an example that stops working fails the build, not just looks stale.
Common Misconceptions
Section titled “Common Misconceptions”- “If
cargo testsaysok, the function is correct.” It means every assertion in every test that ran actually held. A#[should_panic]with noexpected, or a test body with noassert!/assert_eq!at all, reportsokwhile verifying nothing meaningful — as theexpecteddemo above shows directly. - “
cargo test --releaseis just a faster way to run the same tests.” It changes real behavior, not just speed: overflow checks are compiled out. A test that legitimately depends on debug-mode panic behavior (or that happens to hide a bug behind release-mode wrapping) can pass or fail differently by profile alone. - “clippy warnings are just style opinions I can ignore.” Some are (naming conventions, for instance). Others —
await_holding_lock,large_enum_variant,redundant_clone— point at a real runtime cost or hazard that happens to also be visible statically. Treat a clippy warning as a hint to go read why before deciding it’s safe to ignore.
Reading AI-generated Code
Section titled “Reading AI-generated Code”AI-generated test suites often produce a test function that runs code and prints the result, without ever actually asserting anything about it — it “looks like a test” (compiles, has a #[test] attribute, runs without panicking) while verifying nothing at all.
🐛 Spot the bug
fn parse_config(input: &str) -> Vec<String> { input.split(',').map(|s| s.trim().to_string()).collect()}
#[cfg(test)]mod tests { use super::*;
#[test] fn test_parse_config() { let result = parse_config("a, b, c"); println!("{:?}", result); // looks like a test, but nothing is actually checked }}Show the answer
test_parse_config calls parse_config and prints the result, but there is no assert!, assert_eq!, or any other check in the function body. This test passes (ok) for any implementation of parse_config, including one that always returns an empty Vec — a regression that silently breaks the function would leave this test green. The fix is not more code, it’s the assertion that was never written: assert_eq!(result, vec!["a", "b", "c"]);.