Skip to content
← Back to explainers

Rust explainer

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

About 9 min to read. Allow extra time to work through examples.

Unit tests live inside your crate and can call its private functions; integration tests in tests/ are separate crates that see only the public API. Covers #[cfg(test)], sharing helpers between integration tests, testing a binary package, custom test targets and two test-related Clippy lints.

This article uses Rust. You can make it your practice mode to browse related content across the site.

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

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_module is in the restriction group, 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_module is in the style group 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 after mod 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.

step 21 of 22

Modules, Visibility, Testing and Docs

Up next Test selection: filters, #[ignore], and the libtest CLI