Skip to content
intermediate Phase 15 · Testing, Benchmarks, and Profiling

Integration Tests and Test Layout

Place tests in the tests directory and share helpers through modules.

40m
0 problems
Topic Progress 0%

One File, One Binary: Why tests/ Files Cannot See Your Private Items

You write use mylib::Config; at the top of tests/api.rs and the compiler answers unresolved import mylib — or, once the path resolves, function is private. The cause is structural, not a path typo: each .rs file directly under tests/ is compiled as its own crate that links your library exactly the way a downstream application would.

mylib/
├── src/
│   ├── lib.rs        → the library crate `mylib`
│   ├── config.rs     → a module inside that crate
│   └── main.rs       → a binary crate, if you have one
└── tests/
    ├── api.rs        → its own crate, links `mylib`
    └── cli.rs        → another own crate, links `mylib`

Follow the consequences, each of which surprises someone once:

  • Files do not share scope. api.rs cannot see anything declared in cli.rs; there is no global test file to import from.
  • Only the public API is reachable — items exported from lib.rs. Private helpers in config.rs are invisible from here. Testing them means a unit test inside mod tests, as Unit Tests and Assertions set up.
  • Every file becomes its own test binary. Thirty files under tests/ means thirty binaries to compile and run, and cargo test --test api executes only the one named.
  • Subdirectories are not auto-discovered as targets. Only top-level .rs files become test crates; their mod trees compile into that crate.

The test itself uses an external-crate path, not crate:::

// tests/api.rs
use mylib::{parse, Config}; // dependency path, exactly like a user's code

#[test]
fn parses_timeout() {
    let cfg = parse("timeout=5").unwrap();
    assert_eq!(cfg.timeout, 5);
}

Because the test sees the library as a dependency, it can only use what a real user can use — and that is the point. Integration tests test the contract, not the implementation. If an internal refactor forces you to rewrite them, your public API leaked. Keep them few, high-level, and outcome-focused; anything deeper belongs beside the code as a unit test.

Two Cargo.toml details complete the picture. Dev-dependencies are available to unit tests, integration tests, tests' helpers, and benches, but never to the shipping build — put tempfile, tokio-test, and pretty_assertions under [dev-dependencies] so they cannot leak into production:

[dev-dependencies]
tempfile = "3"
pretty_assertions = "1"

Auto-discovery can be turned off or made explicit with [autotests] and [[test]] entries (name, path, harness) if your layout is unusual; the default covers nearly everything.

Integration tests also drive your binaries. Cargo hands every test target a compile-time environment variable pointing at the built executable, so a CLI gets end-to-end coverage without hard-coded paths:

// tests/cli.rs
use std::process::Command;

fn bin() -> Command {
    Command::new(env!("CARGO_BIN_EXE_myapp"))
}

#[test]
fn rejects_missing_flag() {
    let out = bin().arg("--config").output().unwrap();
    assert!(!out.status.success());
    assert!(String::from_utf8_lossy(&out.stderr).contains("missing value"));
}

CARGO_BIN_EXE_<name> is set while compiling integration tests, and Cargo builds the binary first — which is exactly why cargo test --test cli runs slower than a unit test: there is a real process, real I/O, and a real exit code. Those are the properties no in-process test can assert.

In a workspace the layout rules are unchanged — only the selection changes: cargo test -p mylib runs one member's suite, cargo test --workspace runs all of them. Knowing that split matters as soon as CI time starts climbing, which is the subject of the last chapter of this topic.

One interview question follows directly from this structure: "where would you test a private helper versus a public API?" Answer with the crate boundary — a child module can see privates, an external crate cannot — and the placement decision falls out on its own.

Sharing Fixtures Without Publishing Them: the tests/common Module Pattern

Five files under tests/, each re-declaring fn seed_user() -> User { … }. You change the fixture and edit five places — and miss the fifth one.

Cargo's rules create the temptation: test targets are only top-level .rs files under tests/, and a target can reach your library's public API plus its own mod tree. Nothing else is in scope, so duplicated helpers feel inevitable. The supported answer is a module directory:

tests/
├── common/
│   └── mod.rs        ← helpers; never becomes its own test target
├── orders.rs         ← `mod common;`
└── billing.rs        ← `mod common;`
// tests/common/mod.rs
use mylib::User;
use std::time::Duration;

pub fn seed_users() -> Vec<User> {
    vec![User::new("ada"), User::new("grace")]
}

pub const TIMEOUT: Duration = Duration::from_secs(2);
// tests/orders.rs
mod common; // pulls tests/common/mod.rs into THIS target only

#[test]
fn orders_belong_to_the_user() {
    let users = common::seed_users();
    assert!(orders_for(&users[0]).iter().all(|o| o.owner == "ada"));
}

mod common; at the top of every test file is the price of the pattern — there is no automatic global import. In exchange, pub fn inside common is visible only to the test crates that declare it: common is a private module of each test target, not of your library. Nothing leaks into the shipped API, and nothing needs pub on your real types. Shared within tests/, invisible outside it — that is the whole trick.

Watch two sharp edges. Unused helpers trigger dead-code warnings in whichever target declares them; tag genuinely optional helpers #[allow(dead_code)] in common/mod.rs. And helpers that need your library's internals cannot live here at all — this module sees only the public API, same as any test file, so internal-state fixtures belong in #[cfg(test)] mod helpers inside the source file.

Decide placement with one question: who needs this helper?

Helper Put it in
Fixture builders used by two or more test files tests/common/mod.rs
Assertions or setup used by mod tests in src/ a #[cfg(test)] module in that same file
Utilities your downstream users legitimately need the library, behind an explicit feature
Anything requiring private fields or invariants unit tests only

Feature-gating deserves a warning: #[cfg(feature = "test-utils")] pub fn … becomes real, documented API surface the moment a downstream crate enables that feature in release builds. Keep fixtures in tests/ unless you are deliberately publishing a testing utility.

A shared module is also the right home for a builder, which removes even more duplication than a plain function:

// tests/common/mod.rs
pub struct OrderBuilder {
    order: Order,
}

impl OrderBuilder {
    pub fn new(owner: &str) -> Self {
        Self { order: Order::new(owner) }
    }
    pub fn with_item(mut self, sku: &str, qty: u32) -> Self {
        self.order.add(sku, qty);
        self
    }
    pub fn build(self) -> Order {
        self.order
    }
}

Now a test states only what differs about its case — OrderBuilder::new("ada").with_item("A-1", 2).build() — and the fixture's shape can change in one place. That is the real measure of test organization: how much can change before your tests have to.

There is a third sharing story that costs nothing: doc tests. An example inside /// runs as its own test against the public API, so the snippet you would have written in documentation anyway doubles as a contract check — see the final chapter for how to run and select them.

In interviews, "how do you avoid duplicated test setup?" gets a much better answer than "a base class": this directory pattern plus a crate-level fixture builder, with the reasoning that test targets are separate crates — which is also exactly why the duplication existed in the first place.

With fixtures centralized, the remaining problem is scheduling: which slice of the suite runs where, locally and in CI. That is the next chapter's subject.

Running One Slice of the Suite: cargo test -p, Name Filters, and Doc Tests

CI runs fourteen hundred tests across a workspace; you changed one crate. Waiting for the whole suite — or re-running everything locally after each edit — is a workflow bug, and the fix is Cargo's selection flags.

cargo test -p mylib                  # one workspace member
cargo test -p mylib --test orders    # one integration target
cargo test --workspace               # every member
cargo test timeout                   # substring filter on test names
cargo test --lib config              # unit tests only, name-filtered
cargo test -- --exact tests::parses_timeout
cargo test --doc                     # doc tests only
cargo test --no-run                  # compile everything, execute nothing

-p (--package) takes a package name from Cargo.toml — not a path, not a directory. cargo test ./crates/mylib is the common first mistake and fails with a manifest error. In a workspace that sets default-members, bare cargo test already restricts itself; otherwise it builds every member.

Doc tests are a third suite

/// Add two integers.
///
/// ```
/// use mathy::add;
/// assert_eq!(add(2, 2), 4);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

cargo test extracts each fenced Rust block from doc comments, wraps it in fn main implicitly, and compiles and runs it as an independent test with the crate available by name. The rules that bite people:

  • text blocks (and ignore) are skipped; no_run compiles without executing — right for examples that open sockets.
  • Lines prefixed with # are hidden in rendered docs but included in the compiled test, which is how you hide use statements and setup.
  • A doc example that fails to compile is a failing test, so examples cannot rot silently.
You want Command
One crate in a workspace cargo test -p <name>
One integration file cargo test --test <file-stem>
Unit tests only cargo test --lib
Tests matching a name cargo test <substring>
Doc examples only cargo test --doc

Slicing the pipeline

A practical CI layout splits by suite so failures are attributable at a glance:

cargo test --workspace --all-targets   # unit + integration + benches compile
cargo test --doc                       # documentation examples
cargo clippy --all-targets -- -D warnings

Add per-crate caching keyed on Cargo.lock, and --no-run as a separate compilation step: in a monorepo the feedback loop drops from minutes to seconds because unchanged crates stay cached. Locally, a cargo watch-style loop keeps the cycle under a second while you iterate.

For suites past a few hundred tests, swap the runner rather than the tests: cargo nextest run executes each test in its own process, which gives three things libtest cannot — one crashing test cannot take down the rest, per-test timing is reported accurately, and tests run in parallel across processes instead of threads (a real win when your tests touch static state). The test functions themselves do not change.

Interviewers ask "how do you keep a large suite fast?" Answer with three levers: selection (-p, --test, name filters so only affected crates run), parallelism (the harness defaults to one thread per core, and --test-threads=1 is a debugging tool, not a default), and honest test design — anything needing a real network or a slow external service belongs in an #[ignore]d tier that CI runs nightly, not on every push.

Notice what selection does not solve: if a test needs to talk to a database it cannot reach in CI, no flag fixes that. The answer is substituting the dependency, which is where Test Doubles with Traits begins.

Quiz

1. You add tests/admin.rs and inside it write `use crate::helper;` referring to an item in src/lib.rs. What is the correct diagnosis?

Question 1 options

2. Three files under tests/ need the same fixture builder. Where does the shared code go?

Question 2 options

3. In a workspace with packages `api`, `worker`, and `shared`, you changed only `api`. Which command runs just that package's tests?

Question 3 options

4. A doc comment example calls your public API with wrong arguments and fails to compile. What does cargo test report?

Question 4 options

Flashcards

Question

What exactly is a top-level file in tests/, from the compiler's point of view?

Answer

A separate crate: Cargo compiles it with its own main/test harness, links your library as an external dependency, and produces one test binary per file. It sees only items exported from lib.rs, shares no scope with other test files, and subdirectories are not auto-discovered as targets — only their modules compile into the owning file's crate.

Question

How do integration tests share helper code without exposing it from the library?

Answer

Create tests/common/mod.rs (or any directory with mod.rs) and declare `mod common;` at the top of each test file that needs it. The module is private to each test crate, so its pub fns are visible only there; unused helpers trigger dead-code warnings, which #[allow(dead_code)] resolves. Nothing leaks into the shipped API.

Question

How does Cargo discover integration tests, and how do you run only one?

Answer

Every top-level .rs file directly under tests/ becomes a test target automatically (autotests, controllable in Cargo.toml with [autotests] and [[test]]). Run a single one with `cargo test --test <file-stem>`, e.g. `cargo test --test orders`; subdirectory mod.rs files are not separate targets, they belong to the file that declares them.

Question

What does cargo test --doc do, and what makes a doc example fail?

Answer

It runs only the doc-test suite: fenced Rust blocks in /// comments are extracted, compiled against the crate as external code, and executed. A missing function, type mismatch, or panicking example all fail the test; ```ignore``` skips it, ```no_run``` compiles only, and ```# ```-prefixed lines run but stay hidden in rendered docs.

Question

How do you run one crate's tests in a workspace, and how do you split suites in CI?

Answer

cargo test -p <package-name> selects one member (name, not path); --workspace runs all; default-members can make bare cargo test already selective. In CI, split stages: `cargo test --workspace --all-targets` for unit+integration, `cargo test --doc` for examples, clippy separately, with caching keyed on Cargo.lock and --no-run for a compile-only warm step.

Revision Notes

Key Takeaways

  • 1. Every top-level tests/*.rs file is its own crate linking the library — that single fact explains path syntax, visibility limits, per-file binaries, and why helpers do not share scope
  • 2. Fixtures shared across test files live in tests/common/mod.rs declared with `mod common;`, keeping them pub inside the test crate and invisible outside it
  • 3. Doc tests are a third suite: fenced Rust blocks in /// comments are compiled and run, so documentation cannot silently rot
  • 4. cargo test -p <name> selects a workspace member by package name; paths and directory arguments are rejected
  • 5. CI speed comes from selection and caching (per-crate keys, --no-run) plus an honest slow tier for tests needing real infrastructure

Interview Tips

  • • "What's the difference between a unit test and an integration test in Rust?" — the crate boundary: a unit test is a child module that sees privates, an integration test is an external crate that sees only the public API
  • • "How do you share test helpers in Rust?" — tests/common/mod.rs plus `mod common;`, and be ready to say why there is no automatic global import (independent test crates)
  • • "How would you speed up a slow monorepo test suite?" — name -p/--workspace selection, Cargo caching keyed on Cargo.lock, a compile-only --no-run step, parallel harness defaults or nextest, and isolating slow tests behind #[ignore]
  • • "What executes when I type cargo test?" — three suites: unit, integration, doc tests; give one narrowing flag for each (--lib, --test <name>, --doc)

Cheat Sheet

Integration Tests and Test Layout

Layout rules:

mylib/
├── src/lib.rs          library crate (pub items = the contract)
├── tests/
│   ├── common/mod.rs   helpers; never a target itself
│   ├── orders.rs       own crate → links mylib
│   └── cli.rs          own crate → links mylib
Question Answer
Can a test file see another test file? No — independent crates
Can it see private items in src/? No — only pub exports
Shared helper? tests/common/mod.rs + mod common;
Test fixture needing privates? unit test inside mod tests
Drive the built binary? env!("CARGO_BIN_EXE_<name>")

Selection cheat sheet:

cargo test -p api                 # one workspace package (by name)
cargo test --test orders          # one integration file
cargo test --lib                  # unit tests only
cargo test --doc                  # doc examples only
cargo test timeout                # name substring filter
cargo test --no-run               # compile only (CI cache step)
cargo nextest run                 # process-per-test runner

Doc-test controls: ```text skip, ```no_run compile-only, ```ignore opt out, # lines hidden but compiled.

Continue Learning

Before You Start

Related Reading