Integration tests protect the contract
You just finished a function that parses a JSON config. The unit tests pass. You run the binary, and it crashes because the file reader hands the parser a string with a byte-order mark that the parser hates. You tested the pieces. You didn't test the plumbing.
Integration tests catch exactly this gap. They exercise your public API as a user would, forcing all the internal modules to cooperate. If your unit tests are the chef tasting the sauce, your integration tests are the customer eating the meal. The customer doesn't care about the sauce. They care that the fork works, the plate is clean, and the food tastes good together.
The separate crate rule
Rust enforces a strict boundary for integration tests. Every file in the tests/ directory at your project root is compiled as a separate crate. This is not a detail. This is the mechanism that makes integration tests valuable.
Because each file is its own crate, it can only import symbols that are truly public. It cannot see pub(crate) items. It cannot see private helpers. It cannot access internal state. You are locked out of the implementation. You can only touch what you publish.
This separation prevents a common trap: testing implementation details. If your integration test relies on an internal function, the test breaks when you refactor that function, even if the public behavior stays the same. Integration tests should only break when the contract breaks.
// File: tests/basic_api.rs
// This file lives in the tests/ directory.
// Cargo treats it as a standalone crate.
/// Verify the public add function works as documented.
#[test]
fn test_public_add() {
// Import the crate by name, just like an external user would.
// You must use the crate name, not a relative path.
use my_crate::add;
// Call the public function.
let result = add(2, 2);
// Assert the expected outcome.
assert_eq!(result, 4);
}
Convention aside: The directory must be named tests/, not test/. Cargo is strict about this. If you name it test/, Cargo ignores it completely. Also, files in tests/ do not need to be named *_test.rs. You can name them user_flow.rs, api_smoke.rs, or config_load.rs. The name becomes the test target name for cargo test --test.
What Cargo actually does
When you run cargo test, Cargo performs a specific sequence. First, it builds your library in test mode. Second, it compiles every file in tests/ as an independent crate. Each file gets its own main function generated by the test harness. Third, it links those test binaries against your library and runs them.
This means integration tests can have their own dependencies. You can add testing utilities to [dev-dependencies] in your Cargo.toml without bloating your release binary. Tools like tempfile, assert_cmd, or serde_json often live here. They help you write better tests but never ship to your users.
# Cargo.toml
[dev-dependencies]
# These dependencies are only available to code in tests/ and #[cfg(test)] modules.
# They do not appear in the final binary.
tempfile = "3.8"
assert_cmd = "2.0"
Real-world pattern: file I/O
Integration tests shine when you need to verify interactions with the outside world. A unit test might mock a file system. An integration test should hit the real path, or at least the real API surface.
Here is a realistic test that loads a configuration file. It creates a temporary file, writes content, and verifies the parser handles it correctly.
// File: tests/config_integration.rs
use std::fs;
use std::io::Write;
use std::path::Path;
/// Test loading a config file from disk and parsing it.
#[test]
fn test_load_config_from_file() {
// Create a temporary directory for the test.
// Using a unique name prevents collisions with parallel test runs.
let temp_dir = std::env::temp_dir().join("rustfaq_config_test");
fs::create_dir_all(&temp_dir).unwrap();
// Write a valid config file.
let config_path = temp_dir.join("config.toml");
let mut file = fs::File::create(&config_path).unwrap();
writeln!(file, "name = \"test_app\"").unwrap();
writeln!(file, "debug = true").unwrap();
// Import the public API.
use my_crate::Config;
// Load the config using the public method.
// This exercises the file reading and parsing logic together.
let config = Config::load(&config_path).unwrap();
// Verify the fields match the file content.
assert_eq!(config.name, "test_app");
assert!(config.debug);
// Cleanup the temporary directory.
fs::remove_dir_all(&temp_dir).unwrap();
}
Convention aside: In real projects, reach for the tempfile crate instead of manual fs calls. It handles cleanup automatically even if the test panics. Manual cleanup is error-prone. If the test crashes before remove_dir_all, you leave garbage behind. tempfile guarantees cleanup via RAII.
Real-world pattern: CLI binaries
If you are building a command-line tool, integration tests should run the binary itself. You can use assert_cmd to spawn the binary, pass arguments, and check the output. This ensures the CLI interface works exactly as users expect.
// File: tests/cli_integration.rs
use assert_cmd::Command;
/// Verify the CLI exits with code 0 and prints help.
#[test]
fn test_help_flag() {
// Build a command for the binary.
// cargo_bin looks up the binary name from Cargo.toml.
let mut cmd = Command::cargo_bin("my_tool").unwrap();
// Run with --help.
let output = cmd.arg("--help").assert().success();
// Check the output contains expected text.
assert!(output.get_output().stdout.contains("Usage"));
}
Convention aside: assert_cmd is the standard for CLI integration tests in the Rust ecosystem. It handles building the binary, spawning the process, and capturing output. Don't write your own process spawning logic. Use the tool everyone else uses.
Pitfalls and compiler errors
Integration tests trip up on a few specific patterns. Knowing these saves debugging time.
Accessing pub(crate) items
You try to import a helper function that is marked pub(crate). The compiler rejects this with E0603 (function is private). Integration tests cannot see pub(crate) items. They only see pub items.
If you need to test something that isn't public, it belongs in a unit test. Or you need to make it public. If you find yourself making things public just to test them, you might be leaking implementation details. Fix the design. Don't weaken the test.
Shared code across test files
You have five test files, and they all need a helper function to create a test database. You copy-paste the function into every file. This violates DRY. When the helper changes, you update it in five places.
The solution is a tests/common.rs file. Cargo treats tests/common.rs as a module that other test files can import.
// File: tests/common.rs
// This file is a module, not a separate crate.
// Other test files can import it with `mod common;`.
/// Create a test database connection.
pub fn create_test_db() -> Database {
Database::new(":memory:")
}
// File: tests/user_flow.rs
// Import the common module.
mod common;
#[test]
fn test_user_creation() {
// Use the shared helper.
let db = common::create_test_db();
// ...
}
Convention aside: Some projects prefer a separate test-helpers crate instead of tests/common.rs. This works well if multiple crates in a workspace need the same test helpers. For a single crate, tests/common.rs is simpler.
Running specific tests
You have fifty integration tests. Running all of them takes two minutes. You only want to run the config tests. Use cargo test --test config_integration. This runs only the file tests/config_integration.rs. You can also filter by test name: cargo test --test config_integration test_load_config.
When to use what
Testing is a spectrum. Pick the right tool for the job.
Use unit tests when you need to verify internal logic, edge cases, and private functions. They run fast and give precise failure locations. Write them alongside the code.
Use integration tests when you need to verify the public API works as a user expects. They catch interaction bugs between modules and ensure the interface is stable. Write them to protect the contract.
Use doc tests when you want to demonstrate usage in documentation. They keep examples alive and serve as the first line of defense for API changes. Write them in the /// comments above public items.
Use property-based tests when you need to generate thousands of random inputs to find edge cases you haven't thought of. They complement example-based tests by exploring the input space.
Unit tests protect the implementation. Integration tests protect the contract. You need both. Don't let integration tests become slow unit tests that mock everything. If you mock the database, you're just writing a slow unit test. Hit the real path.