The gap between your code and your users
You just finished a library crate. The unit tests pass. The main function runs without panicking. You push the code. A user opens an issue: "I can't import process_order from the public API." You look at your code. The function is pub, but it lives in a module that isn't re-exported. Your unit tests could see it because they live inside the crate. The user can't.
Integration tests catch this gap. They test your crate the way the world sees it. They force you to interact with your code through the public interface, exactly as an external dependency would. If the integration test compiles, your API works. If it fails, you've broken the contract with your users.
What integration tests actually are
Unit tests live inside your source files. They see private functions and internal state. They verify the gears turn correctly. Integration tests live outside. They only see what you export. They verify the watch tells time.
Rust enforces this separation physically. Unit tests go in src/. Integration tests go in a tests/ directory at the crate root. Each file in tests/ is a separate crate compiled against your library. This isolation is the point. If your integration test can access a private helper, you've broken the boundary.
Convention aside: You don't need #[cfg(test)] in the tests/ directory. Cargo only compiles files in tests/ when you run tests. The attribute is for unit tests inside src/ that you want to exclude from production builds. In tests/, the directory itself acts as the filter.
Minimal setup
Create a tests/ folder at the root of your crate, next to src/ and Cargo.toml. Add a file named integration_test.rs.
// tests/integration_test.rs
// This file is a separate crate. It imports your library like any other dependency.
use my_crate::add;
/// Verify the public add function returns the correct sum.
#[test]
fn test_add_public_api() {
// Call the function through the public interface.
let result = add(2, 2);
// Assert the result matches expectations.
assert_eq!(4, result);
}
Run the test with cargo test. Cargo finds the tests/ directory automatically. To run only this specific test file, use cargo test --test integration_test. The file name becomes the test binary name.
Run cargo test --test integration_test to target just that file. The file name becomes the test binary name.
What happens under the hood
When you run cargo test, the compiler does two passes. First, it builds your library crate. Second, it treats every .rs file inside tests/ as a standalone binary crate. It links that binary against your library.
This means the test code must use use my_crate::... to import symbols. It cannot reach into src/lib.rs and grab private items. If you rename a public function, the integration test fails to compile. That failure is a feature. It proves the public API changed.
The compiler also checks trait bounds and visibility rules strictly. If your function requires T: Clone and the test passes a type that doesn't implement Clone, you get a trait bound error. Integration tests exercise the full type system, not just the logic.
Realistic example with structure
Real projects have multiple integration tests. You can organize them by feature. Create separate files for separate concerns.
// tests/user_creation.rs
// Tests for the User struct public API.
use my_crate::{User, Error};
/// Verify that empty names are rejected by the constructor.
#[test]
fn user_rejects_empty_name() {
// Create a user through the public constructor.
let result = User::new("");
// Assert the error variant matches expectations.
assert!(result.is_err());
assert_eq!(result.unwrap_err(), Error::InvalidName);
}
/// Verify that valid names produce a usable user instance.
#[test]
fn user_accepts_valid_name() {
let user = User::new("Alice").expect("valid name should succeed");
// Check the getter returns the stored name.
assert_eq!(user.name(), "Alice");
}
Group tests by feature, not by type. Your tests/ directory should mirror your user's mental model. A file named tests/user_creation.rs tells a reader exactly what behavior is being verified.
Sharing helpers across integration tests
You often need setup code. Creating a database connection or a mock server might repeat across files. The tests/ directory is itself a crate root. You can add modules inside it.
// tests/common/mod.rs
// Helper functions shared across integration tests.
// These are private to the test crate, not your library.
use my_crate::Database;
/// Create an in-memory database for testing.
pub fn create_test_db() -> Database {
// Use the public API to set up test fixtures.
Database::in_memory()
}
Use the helper in your test files.
// tests/database_ops.rs
mod common; // Import the helper module.
use my_crate::QueryResult;
use common::create_test_db;
/// Verify queries return empty results on a fresh database.
#[test]
fn query_returns_empty_on_fresh_db() {
let db = create_test_db();
// Execute a query through the public interface.
let result = db.query("SELECT * FROM users");
assert_eq!(result, QueryResult::Empty);
}
This pattern keeps your test files clean. The helpers live in tests/common/ and stay invisible to the library crate. You can refactor test infrastructure without touching your public API.
Common pitfalls and compiler errors
Visibility traps are the most common issue. You write a test that calls helper_function. The compiler rejects it with E0603 (function is private). This happens because the test is a different crate. The fix is to mark the function pub. If you don't want it public, move the test to a unit test inside src/.
Binary crate confusion trips up many developers. Your project only has src/main.rs. You create tests/test.rs. The test fails to compile with E0432 (unresolved import). Integration tests require a library target. Rust needs something to link against. Add src/lib.rs even if it's empty, or re-export symbols from main. The standard pattern is to put logic in src/lib.rs and keep src/main.rs as a thin wrapper that calls the library.
Another trap is assuming tests/ runs in the same process as unit tests. It doesn't. Each file in tests/ runs as a separate binary. Global state doesn't carry over. If you rely on a static variable initialized in main, your integration test won't see it. Initialize state inside each test or use a helper module.
Don't fight the visibility system. If a test needs a private function, move the test inside the crate.
When to use integration tests versus alternatives
Use integration tests when you need to verify the public API works as external users expect. Use integration tests when you want to catch breaking changes to visibility or module structure. Use integration tests when you are testing interactions between multiple public types or functions. Use unit tests when you need to exercise private logic or edge cases inside a single function. Use unit tests when performance matters; they compile faster because they don't rebuild the crate boundary. Use doc tests when you want examples in your documentation to stay executable.
Trust the separation. Unit tests for the engine, integration tests for the car.