Testing private functions
You wrote a helper function that parses a weird date format. It lives inside your lib.rs module, marked fn parse_date(...) without pub. It works when you call it from the public API, but you want to test the edge cases directly. You try to write a test in tests/integration.rs, and the compiler screams that the function is private. You feel stuck. Do you make the function public just to test it? That exposes implementation details to the world. Do you give up on testing the helper? That leaves bugs hiding.
Rust gives you a clean way to test private code without leaking it. You write the test inside the same module. The compiler treats the test code as a teammate, granting it access to everything the module can see.
The module boundary
Rust modules act like physical folders. A private function is a note scribbled on a sticky note inside that folder. Only code written inside the same folder can read that note. Tests are just code. If your test code lives inside the same module as the function, the compiler lets it see the private items. If the test lives outside, the compiler blocks it.
This isn't a limitation. It's a feature. It forces you to decide what your public API looks like. Private functions are implementation details. If you can test them from anywhere, you risk coupling your tests to internal structure. Rust keeps you honest. You have to opt-in to testing private logic by placing the test where the logic lives.
Child modules have full access to their parent's private items. When you define a submodule inside your code, that submodule can call any function in the parent, even if the function is private. Tests use this rule. The standard pattern creates a test submodule inside the module you want to test. The test submodule becomes a child. It inherits access to the parent's private functions.
The standard pattern
The community standard uses the #[cfg(test)] attribute combined with a mod tests block. This pattern keeps test code out of your production binary while giving you full access to private items.
/// Calculates the discount based on customer tier.
fn calculate_discount(tier: u8) -> f64 {
match tier {
0 => 0.0,
1 => 0.1,
_ => 0.2,
}
}
// The #[cfg(test)] attribute tells the compiler to include this block
// only when the `test` configuration flag is set.
#[cfg(test)]
mod tests {
// Import all items from the parent module.
// This brings private functions into scope for the tests.
use super::*;
#[test]
fn test_tier_one_discount() {
// Call the private function directly.
let discount = calculate_discount(1);
assert_eq!(discount, 0.1);
}
}
When you run cargo test, Cargo passes the --cfg test flag to the compiler. The compiler sees #[cfg(test)] and includes the mod tests block. Without that flag, the block disappears entirely from the build. Your production binary contains zero test code.
Inside the block, mod tests creates a child module. Child modules can see private items of their parent. The use super::* line pulls those names into the test scope so you don't have to type super::calculate_discount every time. The super keyword refers to the parent module. The * imports all names.
The test module is a child. Children inherit access.
A realistic scenario
Real code often delegates work to private helpers. Testing those helpers directly catches bugs faster than testing only the public entry point. If the helper has a bug, the public function fails. Testing the helper gives you immediate feedback on the logic itself.
/// Parses a raw config line into a key-value pair.
pub fn parse_entry(line: &str) -> Option<(String, String)> {
// Delegate to the private helper for the messy splitting logic.
split_and_trim(line)
}
/// Splits the line on '=' and trims whitespace from both parts.
/// Returns None if the format is invalid.
fn split_and_trim(line: &str) -> Option<(String, String)> {
// Split into at most two parts on the first '='.
let mut parts = line.splitn(2, '=');
// Get the key, trim whitespace, and convert to String.
// Return None if the key is missing.
let key = parts.next()?.trim().to_string();
// Get the value, trim whitespace, and convert to String.
// Return None if the value is missing.
let value = parts.next()?.trim().to_string();
Some((key, value))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_and_trim_whitespace() {
// Verify the helper handles messy input correctly.
let result = split_and_trim(" name = value ");
assert_eq!(result, Some(("name".to_string(), "value".to_string())));
}
#[test]
fn test_split_and_trim_missing_value() {
// Verify the helper returns None when the value is absent.
let result = split_and_trim("key=");
assert_eq!(result, Some(("key".to_string(), "".to_string())));
}
}
The split_and_trim function does the heavy lifting. It handles splitting, trimming, and error cases. If you only test parse_entry, you're testing the delegation. Testing split_and_trim directly validates the core logic. If you refactor split_and_trim later, the test ensures the behavior stays correct.
Test the logic where it lives. If the helper breaks, the public function breaks. Catch it early.
The pub(crate) middle ground
Sometimes a function needs to be tested from another module within the same crate, but should remain hidden from downstream users. The pub(crate) visibility modifier solves this. It makes an item public within the crate but private to the outside world.
// This function is visible to any module in this crate.
// External crates cannot see it.
pub(crate) fn internal_helper() {
// ...
}
Use pub(crate) when you have a shared utility function used by multiple modules in your crate. You can test it from a central test module or from the modules that use it, without exposing it in your public API.
Convention aside: The community prefers pub(crate) over pub for intra-crate sharing. It signals intent. Readers know the function is an implementation detail, not part of the stable contract.
Use pub(crate) to share within the crate without leaking to the world.
Common pitfalls
Putting tests in the tests/ directory is the most common mistake. Files in tests/ are integration tests. They compile as separate crates. They see your library as an external user would. External users cannot call private functions. If you try to call a private function from an integration test, the compiler rejects you with E0603 (function is private).
error[E0603]: function `private_helper` is private
--> tests/integration.rs:5:18
|
5 | crate::private_helper();
| ^^^^^^^^^^^^^ private function
This error means your test is in the wrong place. Move the test inside the module using the #[cfg(test)] pattern. Integration tests are for validating the public API. Unit tests are for validating internal logic. Keep them in their lanes.
Another pitfall is testing implementation details too aggressively. If your test checks the exact structure of a private data structure, refactoring becomes painful. Changing the structure breaks the test, even if the public behavior stays the same. Test the behavior of private functions, not their internal representation. If the private function returns a value, test the value. If it modifies state, test the observable state changes.
Integration tests validate the contract. Unit tests validate the machinery.
When to use what
Use the #[cfg(test)] mod tests pattern inside the module when you need to verify private helpers, internal state, or implementation details that don't belong in the public API.
Use pub(crate) visibility when a function needs to be tested from other modules within the same crate but should remain hidden from downstream users.
Use integration tests in the tests/ directory when you want to validate the public API as an external user would, ensuring your public contracts hold.
Avoid making a function pub solely for testing. If you find yourself exposing implementation details just to reach them with a test, move the test inside the module instead. Exposing internals for tests creates a fragile API. Users might start depending on functions you never intended to be stable.
Trust the visibility rules. They protect your API surface.