14.4Integration tests
Unit tests (lesson 14.3) check small pieces from the inside, with access to private internals. Integration tests do the opposite: they live outside your library, see only its public API, and exercise it exactly the way an external user would. Both kinds matter, and the difference is the whole point. This lesson is also where lesson 13.5's library/binary split pays the IOU it promised.
The tests/ directory
Integration tests go in a tests/ directory at the top of your package, beside src/ (not inside it):
my_lib/
├── Cargo.toml
├── src/
│ └── lib.rs
└── tests/
└── integration.rs
Each file in tests/ is compiled as its own separate crate that depends on your library, just like any external user. So an integration test brings your library into scope with use and calls its public functions:
// src/lib.rs
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
fn secret_helper() -> i32 { // private
42
}// tests/integration.rs
use my_lib::add;
#[test]
fn adds_through_public_api() {
assert_eq!(add(2, 3), 5);
}
cargo test runs these alongside your unit tests, reporting them in their own section:
running 1 test
test adds_through_public_api ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Notice two things. There's no #[cfg(test)] and no mod tests: every file in tests/ is inherently test-only, so the attribute is unnecessary. And the test reaches add via use my_lib::add, the package name, because the test crate is separate from the library crate.
They can only see the public API
The defining constraint: an integration test can call add (public) but cannot call secret_helper (private). Try it and the compiler refuses, because from the test crate's vantage point secret_helper doesn't exist; it's not part of the library's public surface (lesson 13.3). This is a feature, not a limitation. Integration tests verify the contract you actually promise to users, the public API, free of any knowledge of how it's implemented inside.
Key insight
Unit tests ask "does this internal piece work?"; integration tests ask "does the library work the way a user will actually use it?" Because integration tests see only the public API, they catch a different class of bug: a function that works in isolation but is awkward, mis-wired, or under-exposed when assembled into the real public surface. They also act as a check on your API design: if your own integration test is clumsy to write, your users will find the API clumsy too.
Why this needs lib.rs
Here's the payoff lesson 13.5 promised. An integration test depends on your crate and calls its public functions, which means there must be a library crate with a public API to call. A binary crate (just main.rs) exposes only an executable; there are no public functions for tests/ to use. So a program whose logic lives entirely in main.rs can't be integration-tested at all.
This is precisely why the advice was to put logic in lib.rs and keep main.rs thin. With the logic in a library crate, your integration tests in tests/ exercise it fully, and main.rs becomes a shell so small it barely needs testing. The two chapters click together: chapter 13 said "structure it this way," and the reason was "so chapter 14 can test it this way." When chapter 20 builds a real command-line tool, this is exactly how it's tested.
Shared test helpers
Integration tests often need shared setup code (a helper that builds a fixture, say). You can't just put it in a file in tests/, because every file there is its own test crate and would try to run the helper as a test. The convention is a subdirectory: tests/common/mod.rs. Files in a subdirectory of tests/ are not compiled as separate test crates, so tests/common/mod.rs is shared module code that your test files pull in with mod common;. You won't need this until your integration tests grow, but it explains the occasional tests/common/ you'll see in real projects.
Quiz time
Question #1
Where do integration tests live, and how is each file there treated by the compiler?
Show solution
In a tests/ directory at the top of the package, beside src/. Each file in tests/ is compiled as its own separate crate that depends on your library, so it sees only the public API and needs no #[cfg(test)] (the whole file is test-only by virtue of being in tests/).
Question #2
Why can't an integration test call a private function from your library?
Show solution
Because the integration test is a separate crate that depends on your library and can only see its public API (lesson 13.3). Private items aren't part of that public surface, so from the test crate's perspective they don't exist. This is intentional: integration tests verify the contract you promise to users, not the internals.
Question #3
Why does integration testing require putting logic in lib.rs rather than main.rs?
Show solution
Integration tests depend on your crate and call its public functions, so there must be a library crate with a public API. A binary crate (main.rs only) exposes just an executable, with no public functions for tests/ to use. Putting the logic in lib.rs gives the integration tests something to call; the thin main.rs is then almost nothing to test (lesson 13.5).
You've now got unit tests inside the code and integration tests outside it. There's a third kind you met in chapter 13 without running them: the examples in your doc comments. The next lesson turns those into tests, documentation that can't go stale.