This article covers two kinds of test that Cargo runs, and they differ in how they are compiled. A unit test is compiled as part of the crate it tests. An integration test is compiled as a separate crate that uses yours as a dependency. Most of the practical differences follow from that.
(A crate is one unit of compilation: a library or a binary. A package is
a directory with a Cargo.toml, and it can contain one library crate plus
any number of binaries, tests, examples and benchmarks.)
| unit tests | integration tests | |
|---|---|---|
| where | in your crate's source files, usually a mod tests at the end of the file under test |
each .rs file directly in tests/ |
| compiled as | part of your crate, in a separate test build | a separate crate per file |
| can call | any item the test module can reach, including private ones | only your library's public API |
#[cfg(test)] on the module |
the usual convention | not needed |
cargo test also runs documentation tests (code examples in /// comments),
which are another kind of external test. This article does not cover them.
Unit tests and private items
The usual pattern is one module at the end of the file:
fn normalise(s: &str) -> String { // private: no `pub`
s.trim().to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trims_and_lowercases() {
assert_eq!(normalise(" Hi "), "hi");
}
}
use super::*; imports every name from the parent module, the one that
defines normalise. The test can call normalise because of Rust's privacy
rule: an item without pub is visible in the module that declares it and in
all of that module's descendants. mod tests is a child of that module, so no
pub is needed. The reverse is not true: the parent cannot see private items
declared inside tests.
#[test] marks a function as a test. cargo test compiles your crate with
rustc --test, which sets the test configuration option and builds a
program that runs every #[test] function and reports the results.
#[cfg(test)] means "compile this item only when test is set". #[test]
functions are already left out of ordinary builds on their own, so the
attribute on the module is mainly for everything else inside it: imports,
helper functions and test data. Without it, those would be compiled into your
normal build, and the compiler would warn that they are unused. Code under
#[cfg(test)] is not compiled by cargo build, and it is not compiled when
another crate depends on yours. Note that it is still source code in your
package: if you publish the crate, the test code is included in the uploaded
source files.
Unit tests in src/main.rs work the same way: cargo test builds the binary
in test mode too.
Check your understanding
use super::*; is a wildcard import, and Clippy has a lint against wildcard imports. Why does a normal test module not trigger it?
Show answer
Hide answer
First, clippy::wildcard_imports is in the pedantic group, which is off by
default. You only see it if you enable it, for example with
#![warn(clippy::pedantic)].
Second, when it is enabled, the lint deliberately skips some cases. In Clippy
1.98 it reports nothing in a crate compiled for testing (with rustc --test,
as cargo clippy --tests does). A module marked #[cfg(test)] exists only in
that test build, so its wildcard imports are never reported, whatever the
module is called: #[cfg(test)] mod checks is skipped too. A plain
mod tests without #[cfg(test)] is also compiled in the normal build, and
there the lint does report it. The lint also skips imports whose path
contains a module named prelude, such as use some_crate::prelude::*;.
The reason for the exemptions is that the lint is about readability. A wildcard import can make it hard to tell where a name came from, but in a test module the answer is obvious: the names come from the code under test. The exact exemptions have changed between Clippy versions, so check the lint's documentation for the toolchain you use.
Integration tests: a separate crate
Cargo treats each .rs file directly inside tests/ as its own test crate.
Each one is compiled separately and depends on your library the way another
project would, so it refers to the library by its crate name:
// tests/api.rs, in a package whose library crate is `image_proc`
use image_proc::Decoder;
#[test]
fn decodes_a_png() {
// ... call Decoder through its public methods ...
}
These files do not need #[cfg(test)], because Cargo builds them only when
test targets are requested, as cargo test does.
An integration test can use only what your library makes public. Trying to
import a private function fails with error E0603, which reports that the
function is private.
Items marked #[cfg(test)] inside the library are not available either,
because the library is compiled normally, without test set, when an
integration test uses it. This is useful: an integration test exercises your
crate through the same interface other crates have, so if something is hard to
express in an integration test, it is hard for your users too.
Sharing helpers between integration tests
Suppose three integration test files need the same setup code, and you move it into a new file:
tests/api.rs
tests/db.rs
tests/common.rs <- shared helpers, no #[test] functions
tests/api.rs can use it with mod common;. But tests/common.rs is also
directly inside tests/, so Cargo builds it as a test crate of its own. Every
cargo test run now includes an extra section for tests/common.rs that
reports running 0 tests. Nothing fails and there is no error message, which
is why this is easy to miss.
The usual fix is to move the file into a subdirectory:
tests/common/mod.rs
Cargo does not treat tests/common/mod.rs as a test crate, so it is compiled
only as a module of the test files that declare mod common;. One exception
to know about: Cargo does treat tests/<name>/main.rs as a test crate, so do
not name the helper file main.rs.
Testing a binary package
A package can contain a library (src/lib.rs), a binary (src/main.rs), or
both. When it has both, they are two separate crates, and the binary uses the
library through its public API just as an outside crate would.
This matters for integration tests. They can import functions only from a
library crate. In a package with only src/main.rs, use my_app::... in a
test file fails to compile (error E0432, "unresolved import"), because there
is no library to import from.
Integration tests in a binary-only package can still run the compiled program
as a separate process. Cargo builds the package's binaries before running
integration tests and gives the tests the path to each one in an environment
variable named CARGO_BIN_EXE_<name>:
// tests/cli.rs, in a package whose binary is named `greet`
use std::process::Command;
#[test]
fn prints_hi() {
let out = Command::new(env!("CARGO_BIN_EXE_greet")).output().unwrap();
assert_eq!(out.stdout, b"hi\n");
}
That tests only what the program prints and returns, not its internal
functions. A common layout is to put the logic in src/lib.rs and keep
src/main.rs small: it reads arguments, calls library functions and reports
the result. The library's functions can then be tested directly from tests/,
and other programs can reuse them.
Configuring test targets in Cargo.toml
Cargo finds test files automatically, but you can also declare them with a
[[test]] section to change their settings:
[features]
heavy = []
[[test]]
name = "slow"
path = "tests/slow.rs"
required-features = ["heavy"]
[[test]]
name = "custom_harness" # path defaults to tests/custom_harness.rs
harness = false
required-features makes Cargo skip the target unless all of the listed
features are enabled. With this configuration, plain cargo test does not
build or run tests/slow.rs, and cargo test --features heavy does. Cargo
skips it silently, so check the output if you expect it to run.
harness = false tells Cargo not to compile the file with rustc --test.
The file is compiled as an ordinary program instead, so it must define its
own fn main, and #[test] functions in it are not run. cargo test runs
the program and counts the target as failed if it exits with a nonzero status.
This is how crates that provide their own test or benchmark runner work; for
example, the Criterion benchmarking crate asks you to set harness = false on
each [[bench]] target.
File paths in tests
When Cargo runs unit and integration tests, it sets the working directory to
the package root, the directory containing Cargo.toml. A relative path
given to a runtime function such as std::fs::read_to_string("fixtures/x.json")
is therefore resolved from the package root, no matter which file the test is
in.
include_str! works differently. It reads the file at compile time, and its
path is relative to the source file that contains the macro call, not to the
working directory. From tests/api.rs, the same file is
include_str!("../fixtures/x.json"). If you want a runtime path that does
not depend on the working directory, you can build it from
env!("CARGO_MANIFEST_DIR"), which Cargo sets to the package root at compile
time.
Two Clippy lints about test layout
clippy::tests_outside_test_moduleis in therestrictiongroup, so it is off unless you enable it. It warns about a#[test]function that is not inside a#[cfg(test)]module.clippy::items_after_test_moduleis in thestylegroup and warns by default. It warns when the test module is followed by other items in the same file, for example a helper function placed aftermod tests. Moving the test module to the end of the file fixes it.
Both lints look at test code, which plain cargo clippy does not compile. To
see them, run cargo clippy --tests or cargo clippy --all-targets.
These lints check layout conventions, not correctness. If your project
deliberately uses a different layout, you can allow a lint for one item with
#[allow(clippy::items_after_test_module)] on the test module, or for the
whole crate with #![allow(...)] at the top of lib.rs or main.rs.