Skip to content
← All articles

Unit tests, #[cfg(test)], integration tests and the lib+bin split

"How do I test private functions" has an answer in Rust that costs nothing — and it only makes sense once you know that private means "this module and its descendants". Plus the single most cited Cargo testing gotcha.

Rust has two kinds of test, and the difference is not a naming convention or a directory habit. It is a compilation difference, and everything else follows from it.

unit tests integration tests
where inside src/, next to the code in tests/
compiled as part of your crate a separate crate per file
can see everything, including private items only your public API
needs #[cfg(test)] yes no

Unit tests: why they can see private items

The idiom is one block at the bottom of the file:

fn normalise(s: &str) -> String { … }   // private

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

    #[test]
    fn trims_and_lowercases() {
        assert_eq!(normalise("  Hi "), "hi");
    }
}

This works because of the second privacy rule from 10.3: a private item is accessible from its declaring module and all of that module’s descendants. mod tests is a child of the module holding normalise, so normalise is visible to it. No pub needed, no test-only visibility hack, no reflection.

That is a genuinely nice property. In a lot of languages “test the private helper” means either making it public, or accepting a testing framework that breaks encapsulation at runtime. Here the privacy model already had the right shape.

#[cfg(test)] on the module means it is compiled only under rustc --test, so your users never download or compile your test code.

💡clippy::wildcard_imports normally objects to use super::*;. Why does it not object here, and what does that carve-out tell you about the lint? click to reveal

It has an explicit exemption for use super::*; inside a module whose name contains “test”. The lint’s author knew that this exact line is universal, idiomatic and unobjectionable — a test module importing its parent is not “where did this name come from”, it is the entire point.

What the carve-out tells you is that wildcard_imports is a heuristic about readability, not a rule about correctness. Its other carve-out — paths containing prelude — says the same thing. When a lint has to special-case the two places everybody uses the pattern, the lint is expressing a preference with known exceptions, and you are allowed to have your own list of exceptions too.

This is also why the lint is pedantic rather than style.

Integration tests: a separate crate, and why that is the point

Every file directly inside tests/ becomes its own crate, compiled separately, linking your library the same way an external user would:

// tests/api.rs
use image_proc::Decoder;      // by package name — you are an outsider now

#[test]
fn decodes_a_png() { … }

No #[cfg(test)] is needed: the whole file only exists under cargo test. And you can only reach the public API — which mechanically enforces the facade design from 10.8. If your integration test cannot express something, neither can your users. That is not an obstacle; that is the test doing its job.

The gotcha everybody hits once

You factor shared setup out of three integration tests into a helper file:

tests/api.rs
tests/db.rs
tests/common.rs      <- shared helpers

Now cargo test reports a third test binary that runs zero tests, and the output has a stray empty section in it forever. Because tests/common.rs is directly inside tests/, Cargo compiled it as its own test target.

The fix is one directory:

tests/common/mod.rs

Subdirectories are not test targets, so tests/common/mod.rs is compiled only when another test file says mod common;. This is the single most cited Cargo testing gotcha, and it is pure filesystem convention with no error message attached.

The lib + bin split, revisited

From 10.13: a package with both src/lib.rs and src/main.rs builds two crates, and the binary uses the library exactly as an external consumer does.

Here is the testing consequence, and it is the real reason for the advice:

A binary-only package cannot be integration-tested at all. There is no library for tests/ to link against.

So: put the logic in src/lib.rs, and make src/main.rs a thin shell that parses arguments and calls one function. You get integration tests, you get a reusable library, and you get an API you were forced to think about because your own binary is its first consumer.

[[test]] targets and harness = false

You can configure test targets explicitly:

[[test]]
name = "slow"
path = "tests/slow.rs"
required-features = ["heavy"]

[[test]]
name = "compile-fail"
harness = false

harness = false means “do not link libtest; my file has its own fn main() and its exit code is the result”. That single switch is the mechanism behind criterion (benchmarking), trybuild (compile-failure tests) and datatest (parameterised suites). It is worth knowing that it exists, because when you meet one of those crates the “how does this even work” question has a one-line answer.

required-features skips a target unless those features are on — the polite way to have tests that only make sense in some configurations.

And one guarantee worth relying on: tests run with the working directory set to the package root, so include_str!("../fixtures/x.json") and File::open("fixtures/x.json") both behave predictably.

Two lints, and an honest disagreement

  • clippy::tests_outside_test_module (restriction) — every #[test] should be inside a mod tests.
  • clippy::items_after_test_module (suspicious, on by default) — do not put ordinary items after the test module at the bottom of a file.

The second one has an issue tracker with a real argument on it. Some people keep helper functions below the test module deliberately, and the lint calls that a mistake. It fires on a formatting preference, and reasonable maintainers disagree with it. This is the honest state of clippy: most lints are uncontroversial, a few are one team’s house style shipped to everyone, and telling them apart is a skill.