How to Use Test Modules and the #[cfg(test)] Attribute

Wrap test functions in a `#[cfg(test)]` module to run them exclusively with `cargo test`.

When tests need to see behind the curtain

You are building a text parser. You have a helper function skip_whitespace that handles tabs, newlines, and non-breaking spaces. The function is private because external code should not call it directly. You write a test to verify it handles a sequence of mixed whitespace correctly. You put the test in tests/integration.rs. The compiler rejects you with E0603 (function is private). You move the test into lib.rs. Now cargo build compiles the test code into your production binary. Your executable grows. The test code sits there, unused, adding weight. You need a way to keep tests adjacent to the implementation, grant them access to private items, and ensure they vanish from the final build.

The conditional compilation switch

Rust solves this with conditional compilation. The #[cfg(test)] attribute is a directive to the compiler. It says: "Only include this code if the test configuration flag is set." The flag is set when you run cargo test. The flag is unset when you run cargo build or cargo run. The code either exists or it does not. There is no runtime check. There is no overhead. The compiler discards the block entirely when the flag is off.

Think of a factory floor with an attached inspection bay. The inspection bay shares a wall with the assembly line. Inspectors can walk through the wall and examine the machinery up close. They can check internal gears that customers never see. When the factory ships products, the inspection bay is sealed off. It does not go to the customer. The #[cfg(test)] attribute is the door to the inspection bay. It stays open during testing. It locks shut for production.

The mod tests block is the container for the bay. It lives in the same file as the code. This placement is deliberate. Modules in Rust can access private items from their parent module. By placing the test module inside the source file, you grant it visibility into the private implementation. The attribute ensures this visibility never leaks into the distributed binary.

Keep the test module inside the source file. It is the only place where private items are legally visible.

Minimal example

Here is the smallest working pattern: a public function, a conditional test module, and a single test case.

/// Adds two unsigned 64-bit integers.
pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

// Only compile this module when the test flag is active.
#[cfg(test)]
mod tests {
    // Import all items from the parent module.
    // This includes private functions and structs.
    use super::*;

    #[test]
    fn test_addition() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

The #[cfg(test)] attribute sits on the module. The #[test] attribute sits on the function. You need both. The module attribute controls compilation. The function attribute tells the test runner to execute the function. If you omit the module attribute, the test compiles into production. If you omit the function attribute, the function compiles but never runs.

Run cargo test to see it pass. Run cargo build to verify the test code disappears.

How the compiler handles this

When you run cargo test, Cargo invokes rustc with a special configuration flag. The compiler scans your source files. It encounters #[cfg(test)]. The condition evaluates to true. The compiler includes the mod tests block in the compilation unit. Inside the block, use super::* pulls in every item from the parent module. The test function calls add. The compiler generates code for the test. The test runner executes the function.

When you run cargo build, the flag is absent. The compiler scans the source. It encounters #[cfg(test)]. The condition evaluates to false. The compiler skips the entire block. No code generation occurs. No symbols are emitted. The private items remain private to the outside world. The test module is an exception only because it is part of the same crate definition during the test build. External crates cannot see the test module.

The use super::* statement is the key to privacy. In Rust, privacy is scoped to the crate. Items marked pub are visible to other crates. Items without pub are visible only within the current crate. The test module is inside the crate. It is not external. Therefore, it can access private items. The use super::* import brings those items into scope. You can test internal logic without exposing it to the world.

Convention aside: The community almost always names the module tests. It is not required. You could name it unit_tests or checks. But tests is the universal convention. Tools, linters, and other developers expect it. Stick to the name. It reduces cognitive load.

Trust the module system. Privacy boundaries exist for a reason.

Testing private items in practice

Real code often has structs with private fields or helper functions that are hard to test from the outside. The test module pattern lets you verify the internals directly.

/// Represents a user account.
pub struct User {
    username: String,
    // Private field. External code cannot access this.
    password_hash: String,
}

impl User {
    /// Creates a new user with a hashed password.
    pub fn new(username: &str, password: &str) -> Self {
        // In production, use a proper hashing algorithm.
        // This is a placeholder for the example.
        Self {
            username: username.to_string(),
            password_hash: format!("hash_of_{}", password),
        }
    }

    /// Verifies a password against the stored hash.
    pub fn verify(&self, password: &str) -> bool {
        self.password_hash == format!("hash_of_{}", password)
    }
}

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

    #[test]
    fn test_verify_correct_password() {
        let user = User::new("alice", "secret123");
        assert!(user.verify("secret123"));
    }

    #[test]
    fn test_verify_wrong_password() {
        let user = User::new("bob", "pass");
        assert!(!user.verify("wrong"));
    }

    // You can access private fields directly in the test module.
    #[test]
    fn test_internal_hash_format() {
        let user = User::new("charlie", "pwd");
        // This line would fail to compile outside the crate.
        assert_eq!(user.password_hash, "hash_of_pwd");
    }
}

The test test_internal_hash_format accesses password_hash. This is allowed because the test module is inside the crate. If you moved this test to tests/integration.rs, the compiler would reject it with E0603. The test module pattern is the standard way to verify internal invariants without compromising the public API.

Convention aside: Use cargo test -- --nocapture to see print output from tests. By default, Cargo captures stdout and stderr. The --nocapture flag passes through to the test runner and disables capture. This is helpful when debugging with println!.

Leave the private fields private. Test them through the crate boundary, not through exposed getters.

Common pitfalls and conventions

Forgetting the #[cfg(test)] attribute is the most common mistake. If you define a mod tests without the attribute, the module compiles into production. The test functions exist in the binary. They do not run automatically, but they add code size. If the test code calls functions with side effects, those effects might trigger. Always include the attribute.

Forgetting use super::* breaks access to private items. The test module is a separate namespace. It does not automatically inherit the parent scope. If you omit the import, you cannot call the functions you are testing. The compiler rejects the code with E0425 (cannot find value in scope) or E0603 if you try to access private items from a different location.

Confusing #[cfg(test)] with #[test] causes silent failures. If you put #[cfg(test)] on the module but forget #[test] on the function, the function compiles but the test runner ignores it. The test never runs. You might think the test passed because Cargo reports success, but the code never executed. Always mark test functions with #[test].

Mixing unit tests and integration tests creates friction. The #[cfg(test)] module is for unit tests. It tests internal logic and private items. The tests/ directory is for integration tests. It tests the public API as external users would. If you put integration tests in #[cfg(test)], you lose the black-box property. If you put unit tests in tests/, you lose access to private items. Keep them separate.

Convention aside: Cargo supports filtering by name. Run cargo test add to execute only tests whose names contain "add". The filter matches against the full path of the test function. If your test is tests::test_addition, running cargo test addition will find it. You can also run tests in a specific module with cargo test tests::. This syntax uses the module path. It is precise and avoids accidental matches in other modules.

Mark every test function with #[test]. The runner will not guess your intentions.

Decision matrix

Use #[cfg(test)] mod tests in your source file when you need to test private items or internal helpers. This pattern keeps tests adjacent to the code and allows use super::* to bypass privacy checks.

Use the tests/ directory for integration tests when you want to validate the public API as external users would. This forces tests to interact with your crate through its exported interface, catching design flaws.

Use #[cfg(test)] on a standalone function when you have a tiny test utility that does not warrant a full module. This is uncommon; the module pattern is the standard for maintainability.

Use dev-dependencies in Cargo.toml when your tests require crates that are not needed in production. These dependencies are only available when the test flag is set, pairing naturally with #[cfg(test)].

Keep the attribute. Your users do not need your assertions.

Where to go next