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 amod 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.