You just refactored the discount logic
You cleaned up the calculate_discount function to handle VIP customers. The code looks sharper. You run the app, and everything seems fine. Then a user reports that regular customers are getting a 50% discount instead of 10%. You spent twenty minutes staring at the logic before realizing the refactoring broke the base case. A unit test would have caught that in milliseconds. Rust bakes testing into the toolchain so you never have to guess whether your changes broke anything.
Tests are checkpoints, not afterthoughts
Unit tests are small, isolated checks that verify a single piece of logic behaves exactly as expected. Think of them as checkpoints on a highway. You do not check the whole car at every mile. You check the brakes at one station, the lights at another. If the brakes fail, the car stops right there. You get an immediate report on what broke and where.
In Rust, you mark a function as a test with the #[test] attribute. The compiler treats it like a regular function, but cargo test knows to run it and check if it panics. If the function finishes without panicking, the test passes. If it panics, the test fails. The testing framework catches the panic and reports it as a failure instead of crashing the whole process.
Minimal example
Place tests in a mod tests block at the bottom of the file. Use #[cfg(test)] to ensure the compiler only includes this module when running tests. This keeps test code out of your production binary.
// src/lib.rs
/// Adds two integers and returns the sum.
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
// #[cfg(test)] tells the compiler to only compile this module when running tests.
// This keeps test code out of your production binary.
#[cfg(test)]
mod tests {
// Import all public and private items from the parent module.
// This is the standard convention so you can test private helpers too.
use super::*;
#[test]
fn test_add_positive() {
// assert_eq! compares two values.
// If they differ, the test panics with a helpful message.
assert_eq!(add(2, 3), 5);
}
#[test]
fn test_add_negative() {
assert_eq!(add(-1, -1), -2);
}
}
Convention aside: use super::* is the community standard inside test modules. It gives you access to private items without cluttering the namespace with explicit imports. If you only need specific items, import them by name, but super::* is safe and common in unit tests.
Keep tests in the same file. If you have to jump between files to understand the code, the tests are already failing at their job.
What happens when you run cargo test
When you run cargo test, Cargo builds a special test harness. It compiles your library with the test configuration enabled, which includes the mod tests blocks. It then links in the testing framework and runs each #[test] function. The output lists every test, marks it with ok or FAILED, and shows the total time.
running 2 tests
test tests::test_add_negative ... ok
test tests::test_add_positive ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
If a test fails, you get the assertion message, the expected value, and the actual value. No guessing.
running 2 tests
test tests::test_add_positive ... FAILED
test tests::test_add_negative ... ok
failures:
---- tests::test_add_positive stdout ----
thread 'tests::test_add_positive' panicked at 'assertion failed: `(left == right)`
left: `5`,
right: `6`', src/lib.rs:15:9
Cargo also supports filtering. Running cargo test test_add executes only tests whose names contain that substring. This speeds up development when you are iterating on a specific function. You can also pass -- --nocapture to see println! output from tests, which helps when debugging complex state.
Run cargo test often. The feedback loop is fast enough that waiting to run tests before committing is just asking for trouble.
Realistic example with validation and panics
Real code often has branches and error conditions. Test the happy path, the edge cases, and the panic paths. Use #[should_panic] to verify that functions panic when they should. Always include the expected message to ensure the panic comes from the right place.
/// Creates a user handle from a username.
/// Panics if the username is empty or contains spaces.
pub fn create_handle(username: &str) -> String {
if username.is_empty() {
panic!("Username cannot be empty");
}
if username.contains(' ') {
panic!("Username cannot contain spaces");
}
format!("@{}", username.to_lowercase())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_handle_valid() {
let handle = create_handle("RustFan");
// Convention: put the expected value first in assert_eq!.
// The error message reads "left != right", so this makes failures
// read as "expected != actual", which matches your mental model.
assert_eq!("@rustfan", handle);
}
#[test]
fn test_create_handle_lowercases() {
let handle = create_handle("UPPERCASE");
assert_eq!("@uppercase", handle);
}
#[test]
#[should_panic(expected = "Username cannot contain spaces")]
fn test_create_handle_rejects_spaces() {
create_handle("bad user");
}
#[test]
#[should_panic(expected = "Username cannot be empty")]
fn test_create_handle_rejects_empty() {
create_handle("");
}
}
Convention aside: #[should_panic(expected = "message")] is strictly better than bare #[should_panic]. Without the expected message, the test passes if the function panics for any reason, including a random index out of bounds deep in a dependency. The expected message ties the test to the specific failure mode.
Test the panic path too. If your function promises to panic on bad input, verify that it actually does. A silent failure is worse than a loud one.
Pitfalls and compiler behavior
Tests can lie if you are not careful. Here are the common traps.
Forgetting to call the function. If you write a test that defines a variable but never uses it, the test passes. The compiler warns about unused variables, but if you silence the warning with let _ = ..., the test becomes a no-op.
#[test]
fn test_useless() {
let _ = create_handle("test"); // Compiles, passes, does nothing.
}
The test passes because the function is never called. Always assert something or let the result be used.
Swapping assert arguments. assert_eq! prints left != right. If you write assert_eq!(actual, expected), the error message shows the actual value on the left. This is confusing when you are scanning output. Stick to assert_eq!(expected, actual).
Missing #[cfg(test)]. If you forget the attribute, the test module compiles into your production binary. This adds bloat and can cause name collisions. The compiler does not error on this, so it is easy to miss.
Testing private items from the wrong place. If you move tests to the tests/ directory, they become integration tests. Integration tests only see public items. If you try to call a private function, the compiler rejects it with a "cannot find function in this scope" error. Keep unit tests in mod tests to access internals.
Ignoring Result returns. If a test function returns Result, the harness checks it. But if you call a function that returns Result and do not unwrap or check it, the test passes even if the function returns Err.
#[test]
fn test_forgets_check() {
let result = some_function_that_returns_err();
// Test passes because result is never inspected.
}
Always unwrap, use assert!, or return the result from the test function.
A test that does not call the code under test is just a comment that compiles. Delete it.
When to use what
Use mod tests inside your source file when you need to test private functions, structs, or internal logic. This keeps the test close to the code and gives it full access to the module's internals.
Use a tests/ directory at the crate root when you want to test the public API as an external user would. Integration tests in this directory only see public items, which forces you to verify the interface contract.
Use assert_eq!(expected, actual) when comparing values. The macro prints a clear diff on failure, and putting the expected value first makes the error message read naturally.
Use assert!(condition) when checking a boolean predicate that does not have a clear expected value. For example, assert!(vec.is_sorted()).
Use #[should_panic(expected = "message")] when the function is designed to panic on invalid input. Always include the expected message to ensure the panic comes from the right place, not a random index out of bounds elsewhere.
Use cargo test test_name to run a subset of tests during development. The filter matches any test containing the substring, so consistent naming helps you target specific logic quickly.
Unit tests protect your logic. Integration tests protect your API. You need both, and they serve different masters.