How to Test Private Functions in Rust

Test private Rust functions by adding a #[cfg(test)] module and using use super::* to access internal items.

Testing private functions without exposing them

You've written a helper function that parses a weird date format or calculates a checksum. It's private because the rest of the world shouldn't care about the internal logic. You want to write a test for it. You try calling it from a separate test file and the compiler throws a visibility error. The function is hidden behind the module wall.

The fix isn't to make the function public. Making it public pollutes your API and invites users to depend on implementation details. The fix is to move the test inside the wall. Rust's module system lets you nest a test module directly inside the module that owns the private code. That test module gets a backstage pass to everything the parent module can see.

How visibility and modules interact

Rust organizes code into a tree of modules. Visibility rules are relative to that tree. A pub item is visible everywhere. A private item is visible only within the module that defines it and any child modules nested inside it.

When you write a test module inside your source module, that test module becomes a child. It inherits the parent's visibility scope. The compiler treats the test module as a trusted insider. It can call private functions, access private fields, and use private types without complaint.

The use super::*; statement is the key. super refers to the parent module. The wildcard imports every item from the parent into the test module's scope. This includes private items. Without use super::*;, the test module would have an empty scope and wouldn't see anything.

Think of it like an office layout. The private function is in a locked room. The test module is a desk built inside that room. The desk can reach everything on the shelves. A test file in a separate directory is like a desk in the hallway. It can't reach into the locked room unless you unlock the door and make the function public.

Minimal example

Here is the standard pattern. The test module lives inside the same file, nested under the code it tests.

/// Calculates a discount based on internal pricing tiers.
/// This logic is private to this module.
fn calculate_discount(price: f64) -> f64 {
    // Complex tier logic would go here
    price * 0.9
}

/// Test module is conditionally compiled only during testing.
#[cfg(test)]
mod tests {
    // Import everything from the parent module, including private items.
    use super::*;

    #[test]
    fn test_discount_calculation() {
        // Access private function directly.
        let result = calculate_discount(100.0);
        assert_eq!(result, 90.0);
    }
}

The #[cfg(test)] attribute tells the compiler to include this module only when the test configuration flag is set. This happens automatically when you run cargo test. When you build for production with cargo build, the compiler strips the test module entirely. Your binary contains no test code, no test dependencies, and no test-only imports.

Run this with cargo test. The output shows the test passing. Run cargo build and the test code vanishes from the compiled artifact.

The compiler treats your test module as a trusted insider. It gets a backstage pass.

Conditional compilation and cfg(test)

The #[cfg(test)] attribute is a conditional compilation directive. Rust evaluates configuration flags at compile time. If the flag matches, the code exists. If it doesn't, the code is as if it were never written.

When you invoke cargo test, Cargo sets the test config flag and links the standard test harness. The harness provides the #[test] attribute and the assertion macros. It also runs the tests and reports results.

This mechanism keeps your production binary lean. Test code often imports heavy crates like assert_cmd or tempfile. Those crates would bloat your release build if they weren't gated behind cfg(test). They also wouldn't compile in production because the test harness isn't available.

Convention aside: Always wrap test modules in #[cfg(test)]. Some developers skip it for quick experiments, but leaving test code in production can cause subtle issues. Test code might depend on std::test APIs that don't exist in normal builds. It might pull in dependencies that conflict with production requirements. The attribute is the safety net.

Realistic example: Testing private struct methods

Private functions aren't the only thing you might want to test. Structs often have private helper methods that support public behavior. Testing those methods directly can save you from writing convoluted integration tests.

/// Configuration parser that reads key-value pairs.
pub struct Config {
    raw_data: String,
}

impl Config {
    /// Creates a new Config from raw text.
    pub fn new(data: String) -> Self {
        Self { raw_data: data }
    }

    /// Extracts a value for a given key.
    /// Private because callers should use the public API.
    fn parse_value(&self, key: &str) -> Option<&str> {
        self.raw_data
            .lines()
            .find_map(|line| {
                let parts: Vec<&str> = line.splitn(2, '=').collect();
                if parts.len() == 2 && parts[0].trim() == key {
                    Some(parts[1].trim())
                } else {
                    None
                }
            })
    }

    /// Public method that uses the private helper.
    pub fn get(&self, key: &str) -> Option<String> {
        self.parse_value(key).map(|v| v.to_string())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_value_directly() {
        let config = Config::new("host=localhost\nport=8080".to_string());
        
        // Test the private helper directly.
        assert_eq!(config.parse_value("host"), Some("localhost"));
        assert_eq!(config.parse_value("missing"), None);
    }

    #[test]
    fn test_public_api() {
        let config = Config::new("host=localhost".to_string());
        assert_eq!(config.get("host"), Some("localhost".to_string()));
    }
}

The test module accesses parse_value even though it's private. The use super::*; brings Config and its methods into scope. The test calls the method on an instance just like any other code would.

This pattern shines when the private method contains complex logic that's hard to exercise through the public API. If parse_value had edge cases around whitespace or encoding, testing it directly lets you target those cases precisely. You don't need to construct elaborate public calls to trigger the internal path.

Convention aside: Use use super::*; in test modules. It's the community standard. Test modules are local and ephemeral. Explicit imports add noise without value. The wildcard makes it clear that the test has full access to the module's internals.

Pitfalls and compiler errors

Testing private functions works, but it introduces risks. The compiler helps you avoid some, but others require discipline.

E0603: Private function not accessible

If you try to call a private function from outside its module, the compiler rejects you with E0603 (private function/method not accessible). This happens when you put tests in a separate file or in the tests/ directory. Those locations are outside the module tree. They see only public items.

// This fails to compile.
// error[E0603]: function `calculate_discount` is private
fn external_test() {
    let _ = calculate_discount(100.0);
}

The fix is to move the test inside the module or make the function pub. If the function truly belongs to the internal implementation, keep it private and nest the test.

Forgetting #[cfg(test)]

If you omit the #[cfg(test)] attribute, the test module compiles into your production binary. This causes two problems. First, the binary grows. Second, the code might reference #[test] or assertion macros that don't exist in production builds. You'll get errors about unknown attributes or missing crates.

Always wrap test modules in #[cfg(test)]. It's the single line that keeps test code out of release builds.

Testing implementation details

The biggest risk isn't technical. It's architectural. When you test private functions, you couple your tests to the implementation. If you refactor the private function, the test breaks even if the public behavior stays the same. This creates brittle tests that slow down development.

Test private functions only when the logic is complex enough to warrant direct verification. If the function is a simple wrapper or a trivial calculation, test it indirectly through the public API. The public API is the contract. Tests should verify the contract holds.

If you find yourself testing many private functions, ask whether the module is doing too much. Large modules with many private helpers often benefit from being split into smaller, focused modules. Each smaller module can have its own public API and its own tests.

Test the behavior, not the machinery. If the private function is complex enough to need a test, ask if it should be its own public module.

Decision matrix

Use inline mod tests with use super::* when you need to verify internal logic that the public API doesn't expose directly. Use integration tests in the tests/ directory when you want to validate the public interface exactly as an external user would. Use pub(crate) visibility when a function is private to the outside world but needs to be shared across multiple modules within the same crate. Use #[cfg(test)] on every test module to keep test code out of production binaries. Use separate test files when the test setup is complex and requires its own module structure. Use pub visibility only when the function is part of the stable API that users depend on.

Trust the module system. It gives you the tools to test internals without leaking them.

Where to go next