14.3Organizing unit tests
Loose #[test] functions work, but real projects organize them. Rust has a strong convention for unit tests, tests that check one small piece of code in isolation, and following it puts your tests next to the code they exercise while keeping them out of the shipped binary. This lesson covers that convention and a question it makes easy: should unit tests reach into a module's private internals?
The #[cfg(test)] mod tests convention
The idiomatic place for a module's unit tests is a submodule named tests, inside the same file, marked #[cfg(test)]:
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_positives() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn adds_with_zero() {
assert_eq!(add(5, 0), 5);
}
}
Three pieces are doing work here. mod tests is an ordinary module (lesson 13.2) that groups the tests. #[cfg(test)] is the important attribute: it tells the compiler to include this module only when building for tests (cargo test), and to skip it entirely for a normal cargo build. So your tests add nothing to the shipped binary, no size, no compile cost in release builds, while living right beside the code. And use super::*; brings everything from the parent module (the add function) into the test module's scope, so the tests can call it without long paths; super is the parent-module path from lesson 13.2.
running 2 tests
test tests::adds_positives ... ok
test tests::adds_with_zero ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Note the names now read tests::adds_positives: the test's path includes the module, exactly like any other item.
Key insight
#[cfg(test)] is conditional compilation: the tests module exists during cargo test and vanishes during cargo build. That's why unit tests can sit in the same file as the code without bloating your release binary or slowing normal builds. Tests live next to what they test (easy to find, easy to keep in sync) but cost nothing when you're not testing.
Why keep tests in the same file
Putting unit tests in the same file as the code, rather than off in a separate directory, is deliberate. The test is documentation of how that code is meant to behave, and keeping it adjacent means a reader sees the examples right there, and a person changing the function sees the tests they need to keep passing. It also grants a capability the next section explains: same-file tests can see the module's private items.
Testing private functions
Here's where Rust's choice differs from many languages. Because the tests submodule is a child of the module it's testing, it can access that module's private functions (lesson 13.3: a child module can see its ancestors' private items). So unit tests can test internal helpers directly:
fn normalize(text: &str) -> String {
text.trim().to_lowercase()
}
pub fn matches(query: &str, text: &str) -> bool {
normalize(text).contains(&normalize(query))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_trims_and_lowercases() {
assert_eq!(normalize(" HELLO "), "hello"); // tests a private fn
}
#[test]
fn matches_is_case_insensitive() {
assert!(matches("RUST", "Learning rust"));
}
}
normalize is private, yet normalize_trims_and_lowercases tests it directly, because the test module is nested inside the same module. This is occasionally exactly what you want: a tricky internal helper deserves its own focused tests.
Whether you should test private functions is a genuine debate. One camp says test only the public API, on the grounds that private functions are implementation details you should be free to change, and tests tied to them break on every refactor. The other camp says a complex private helper is worth pinning down directly, because a bug there is easier to localize with a targeted test than to chase through the public function that calls it. The reasonable middle: test the public API thoroughly (that's what callers depend on), and add private-function tests sparingly, only for internal logic intricate enough to earn its own safety net.
Best practice
Default to testing through the public API, which is what your users actually rely on and what survives refactoring. Reach into private functions only when a helper is complex enough that a direct test genuinely helps localize bugs. If you find yourself needing to test many private functions, it can be a hint that some of them want to become their own public, separately-tested module (lesson 13.4).
Quiz time
Question #1
What does #[cfg(test)] on the mod tests block do, and why is it important?
Show solution
It compiles the module only when building for tests (cargo test) and excludes it from a normal cargo build. This lets unit tests live in the same file as the code they test without adding anything to the shipped binary or slowing release builds. It's conditional compilation.
Question #2
What does use super::*; accomplish at the top of a tests module?
Show solution
It brings everything from the parent module (the code being tested) into the test module's scope, so the tests can call those functions and use those types by their short names instead of full paths. super refers to the parent module (lesson 13.2).
Question #3
Why can a #[cfg(test)] mod tests block test a private function, and what's the usual advice about doing so?
Show solution
Because the tests module is a child of the module containing the private function, and child modules can access their ancestors' private items (lesson 13.3). The usual advice: prefer testing the public API (what callers depend on, and what survives refactoring), and test private functions only sparingly, when an internal helper is complex enough to deserve its own focused test.
Unit tests live beside the code and can see its internals. The opposite approach, testing only what an outside user can see, is the next lesson: integration tests in the tests/ directory, where the lib.rs split from chapter 13 finally earns its keep.