Testing in Rust
Rust bakes a test runner into the toolchain, so a test is just a function with one attribute above it — no framework, no config file.
Every language eventually needs a story for testing, and Rust's is unusually good: the test runner ships with `cargo`, the assertion macros ship with the standard library, and the convention for where tests live is baked into the module system you learned in the last few lessons. There's no `pip install pytest` equivalent to reach for — `cargo new` already generates a project ready to hold tests, and `cargo test` already knows how to find and run them. That matters more than it sounds: when testing has zero setup cost, you actually write tests, instead of postponing them until the project is 'big enough to need it.'
A test is a regular function marked with `#[test]` above it. `cargo test` compiles your crate in a special test-enabled mode, finds every function tagged `#[test]`, runs each one, and reports pass or fail — a test 'passes' simply by not panicking, and 'fails' the moment it does. Tests run in parallel across threads by default, which is fast but means two tests that both touch the same global resource (a file, an environment variable, a shared database) can interfere with each other; when that happens, `cargo test -- --test-threads=1` forces them to run one at a time while you track down the real issue.
The macros you'll use inside almost every test are `assert!`, `assert_eq!`, and `assert_ne!`. `assert!(condition)` panics with a generic message if `condition` is `false`; `assert_eq!(left, right)` panics with a message showing both values if they aren't equal, which is far more useful for debugging than a bare `assert!(left == right)` would be, because you don't have to add your own printing to see what actually went wrong. `assert_ne!` is the mirror image, panicking if the two values *are* equal. All three accept an optional custom message as extra arguments, formatted the same way `println!` formats its arguments, for when the default failure output isn't enough context.
By convention, unit tests — tests that check one function or module in isolation, often reaching into private implementation details — live in the same file as the code they test, inside a `#[cfg(test)] mod tests` block. The `#[cfg(test)]` attribute tells the compiler to compile that module only when running tests, so none of your test code bloats the release binary. Inside it, `use super::*;` pulls every item from the parent module into scope, which is why the pattern looks almost identical from file to file: a small module at the bottom of a file, testing the functions defined above it, without needing to re-import each one by name.
Unit tests can see private functions because they live inside the crate, but they can't tell you whether your crate's *public* API actually works the way an external user would call it — and that's what integration tests are for. Any file you place in a `tests/` directory at your project's root (a sibling of `src/`) is compiled as its own separate crate that can only see what you've made `pub`, exactly like a real dependent of your library would see. Each file in `tests/` runs independently, and together they're the closest thing Rust has to 'does this crate actually work from the outside.'
Finally, some functions are *supposed* to panic under specific bad inputs, and you need to test that they do. `#[should_panic]` inverts the usual pass/fail logic for a test: it passes only if the function panics, and fails if it completes normally. You can narrow it further with `#[should_panic(expected = "some substring")]`, which additionally checks that the panic message contains that substring — without it, a test could 'pass' by panicking for a completely unrelated reason, which defeats the point of testing that specific failure path in the first place.
pub fn add_two(a: i32, b: i32) -> i32 {a + b}pub fn is_even(n: i32) -> bool {n % 2 == 0}#[cfg(test)]mod tests {use super::*;#[test]fn adds_two_positive_numbers() {assert_eq!(add_two(2, 3), 5);}#[test]fn recognizes_even_numbers() {assert!(is_even(4));assert!(!is_even(7));}}fn main() {println!("2 + 3 = {}", add_two(2, 3));}
The #[cfg(test)] mod tests block only compiles when running cargo test, and use super::* brings add_two and is_even into scope so the tests can call them directly by name.
pub struct Percentage {value: u8,}impl Percentage {pub fn new(value: u8) -> Percentage {if value > 100 {panic!("percentage cannot exceed 100, got {value}");}Percentage { value }}}#[cfg(test)]mod tests {use super::*;#[test]fn accepts_a_valid_percentage() {let p = Percentage::new(50);assert_eq!(p.value, 50);}#[test]#[should_panic(expected = "cannot exceed 100")]fn rejects_a_percentage_over_100() {Percentage::new(150);}}fn main() {let p = Percentage::new(80);println!("value: {}", p.value);}
should_panic(expected = "...") passes only if Percentage::new panics AND the panic message contains that substring, so the test would fail if the code panicked for an unrelated reason instead of the intended validation.
// Simulates the shape of a file at tests/integration_test.rs.// In a real project this file has no module wrapper around it — cargo// treats every file directly under tests/ as its own independent crate// that can only see the pub items of your library.pub fn add_two(a: i32, b: i32) -> i32 {a + b}#[test]fn public_api_adds_two_numbers() {assert_eq!(add_two(10, 15), 25);}fn main() {}
A file placed directly under tests/ needs no #[cfg(test)] wrapper at all — cargo only compiles the tests/ directory when running cargo test, and treats each file there as its own crate that can only reach the pub API.
🧠 Check your understanding
0/1 · 0/1 answered1. You write `#[test] fn parses_valid_input() { ... }` inside `src/lib.rs`, but forget to wrap it in a `#[cfg(test)] mod tests` block. What actually happens when you run `cargo build` (not `cargo test`)?