Rust 47 🦀 Integration Testing in the tests Directory
A unit test verifies a function in isolation. An integration test verifies the library as the consumer uses it. The unit tests live in the src/ files, and they can access the private items. The integration tests live in the tests/ directory, and each file is a separate crate that depends on the library. The integration test calls the public API the same way the external user does, and it verifies the behavior that crosses the module boundaries. The two kinds of tests are the complements, and the cargo test runs both.
The tests/ directory is the convention. The cargo test discovers the .rs files in the tests/ directory and compiles each one as a separate crate. The test files use the library’s name in the use statement, and the library is the dependency. The integration test does not need the #[cfg(test)] module or the use super::*, because it is the separate crate. The #[test] attribute is the same, and the assertions are the same. The difference is the scope: the integration test sees the public API, and the unit test sees the internals.
The integration tests have the several patterns. The single-file test is the standalone file with the tests. The shared utility module is the tests/common/mod.rs that the multiple test files use. The setup and teardown is the std::sync::Once or the #[ctor] for the expensive resources. The cargo test --test name runs the specific integration test file, and the cargo test --test-threads=1 runs them serially. The integration tests are the black-box, and they verify the library as the product.
This chapter covers three areas. First, why the integration tests exist — the black-box problem and the public-API contract. Second, the structure — the tests/ directory, the single file, the shared module, and the cargo test discovery. Third, the patterns — the setup and teardown, the async tests, the test organization, and the best practices. The chapter ends with a complete example session, a quick reference, best practices, common pitfalls, real-world examples, and diagrams showing the integration test organization.
Key point: The integration tests live in the tests/ directory, and each file is a separate crate that depends on the library. The integration tests can only access the public API, and they verify the library as the consumer uses it. The cargo test discovers the .rs files in the tests/ directory, and the cargo test --test name runs the specific file. The tests/common/mod.rs is the shared utilities, and the #[test] and the assertions are the same as the unit tests.
Why the integration tests exist
The black-box problem. The unit test verifies the function with the knowledge of the internals. The integration test verifies the library without the knowledge of the internals. The integration test uses the public API, and it catches the problems that the unit test misses: the missing pub, the wrong module path, the inconsistent API, the broken example. The integration test is the user’s perspective, and the user’s perspective is the contract.
The public-API problem. The public API is the library’s contract. The integration test verifies the contract by calling the public functions in the same way the external crate would. The test that passes in the unit but fails in the integration means the API is not usable as intended. The integration test is the API acceptance test.
The module-boundary problem. The unit tests verify the functions within the module. The integration tests verify the interactions between the modules. The function is correct in isolation, and the combination is broken. The integration test catches the combination problem, and the unit test does not.
The example-verification problem. The documentation example shows how the library is used, and the documentation test verifies the example. The integration test verifies the real usage, and the two complement each other. The documentation example is the minimal, and the integration test is the comprehensive.
The regression problem. The change to the library can break the public behavior. The unit tests still pass, because the internals are correct. The integration tests fail, because the public behavior changed. The integration tests are the regression net for the public API.
The trade-off. The integration tests are the separate crate, and they compile the library as the dependency. The compilation is the slower, and the test runs are the longer. The integration tests are the fewer than the unit tests, and they focus on the public behaviors. The trade-off is between the coverage of the public API and the speed of the unit tests.
a. The structure of the tests directory
The tests/ directory is the sibling of the src/ directory, and the Cargo.toml is the project root.
my_project/
├── src/
│ └── lib.rs
├── tests/
│ ├── integration_test.rs
│ └── common/
│ └── mod.rs
└── Cargo.toml
The cargo test discovers the .rs files in the tests/ directory and compiles each one as a separate crate. The tests/common/mod.rs is the shared module, and the cargo test does not treat it as the separate test file because the mod.rs is the module file, not the test file.
The single integration test.
// tests/integration_test.rs
use my_project::add;
#[test]
fn it_adds_from_the_outside() {
assert_eq!(add(2, 3), 5);
}
The use my_project::add imports the public function from the library. The #[test] marks the test, and the assert_eq! is the assertion. The test runs the same way the unit test does, and the difference is the scope.
The shared utility module.
// tests/common/mod.rs
pub struct TestContext {
pub client: Client,
pub database: Database,
}
impl TestContext {
pub async fn new() -> Self {
let database = Database::connect().await;
let client = Client::new(&database);
TestContext { client, database }
}
pub async fn cleanup(self) {
self.database.drop().await;
}
}
The common/mod.rs is the shared module. The test file imports it:
// tests/integration_test.rs
mod common;
use common::TestContext;
#[tokio::test]
async fn it_creates_the_user() {
let context = TestContext::new().await;
let user = context.client.create_user("Alice").await;
assert_eq!(user.name, "Alice");
context.cleanup().await;
}
The mod common; declares the module, and the use common::TestContext imports the struct. The cargo test does not treat the common/mod.rs as the test file, because the mod.rs is the module.
The cargo test discovery.
cargo test
The output shows the unit tests, the integration tests, and the documentation tests in the separate sections:
running 3 tests (the unit)
test tests::it_adds ... ok
test tests::it_subtracts ... ok
running 1 test (the integration_test)
test it_adds_from_the_outside ... ok
running 1 test (the doc)
test src/lib.rs - add (line 5) ... ok
The cargo test --test integration_test runs only the specific integration test file, and the cargo test --test-threads=1 runs the tests serially.
b. The setup and the teardown
The integration tests often need the expensive setup: the database connection, the file system, the network server. The setup and the teardown patterns are the several.
The Once pattern. The std::sync::Once runs the setup once for the whole test process:
use std::sync::Once;
static INIT: Once = Once::new();
fn setup() {
INIT.call_once(|| {
// The expensive setup
std::env::set_var("DATABASE_URL", "postgres://localhost/test");
});
}
#[test]
fn it_uses_the_setup() {
setup();
// The test
}
The INIT.call_once runs the closure only the first time the setup is called, and the subsequent calls are the no-ops. The pattern is the simple for the one-time setup.
The TestContext pattern. The struct with the new and the Drop:
pub struct TestContext {
pub db: Database,
}
impl TestContext {
pub async fn new() -> Self {
let db = Database::connect("postgres://localhost/test").await;
db.migrate().await;
TestContext { db }
}
}
impl Drop for TestContext {
fn drop(&mut self) {
// The cleanup
let db = self.db.clone();
tokio::spawn(async move {
db.drop_all().await;
});
}
}
The TestContext::new creates the resource, and the Drop cleanup runs when the context goes out of scope. The pattern is the RAII, and it ensures the cleanup even when the test panics.
The serial execution. The integration tests that share the global state (the database, the file system) must run serially:
cargo test -- --test-threads=1
The --test-threads=1 runs the tests one at a time, and the shared state is safe. The alternative is the separate database per test, which is the slower but the isolated.
The #[ignore] for the expensive. The expensive integration test is ignored by default:
#[test]
#[ignore = "the requires the database"]
fn it_uses_the_database() {
// The test
}
The cargo test skips it, and the cargo test -- --ignored runs it. The CI runs the ignored tests in the separate job.
c. The patterns and the best practices
The test organization. The tests/ directory contains the multiple files, and each file is the logical group:
tests/
├── common/
│ └── mod.rs
├── api_test.rs
├── database_test.rs
└── auth_test.rs
The api_test.rs, the database_test.rs, and the auth_test.rs are the separate crates, and the common/mod.rs is the shared. The cargo test --test api_test runs the specific file.
The descriptive names. The integration test description is the behavior:
#[test]
fn it_returns_the_user_when_the_found() { ... }
#[test]
fn it_returns_the_error_when_the_not_found() { ... }
The names are the readable, and the failure message is the clear.
The one concept per test. The integration test verifies one behavior:
// ✅ The one concept
#[test]
fn it_creates_the_user() { ... }
// ❌ The multiple concepts
#[test]
fn it_creates_the_user_and_sends_the_email_and_logs() { ... }
The assert! with the message. The integration test uses the descriptive message:
assert_eq!(user.name, "Alice", "the created user should have the name 'Alice'");
The Result return. The integration test can return the Result:
#[tokio::test]
async fn it_creates_the_user() -> Result<(), Box<dyn std::error::Error>> {
let context = TestContext::new().await;
let user = context.client.create_user("Alice").await?;
assert_eq!(user.name, "Alice");
Ok(())
}
The ? operator propagates the error, and the test fails with the clear message.
The cargo test --test for the specific. The --test flag runs the specific integration test file:
cargo test --test api_test
cargo test --test database_test
The cargo test --test-threads for the parallel. The --test-threads controls the parallelism:
cargo test -- --test-threads=4
The default is the number of the CPU cores, and the 1 is the serial.
The coverage. The cargo test with the --coverage flag or the cargo-tarpaulin measures the coverage:
cargo tarpaulin --out Html
The coverage shows the lines that the tests execute, and the gaps are the untested. The coverage is the metric, not the goal.
The CI integration. The CI runs the tests with the different configurations:
- name: Run tests
run: cargo test --all-features
- name: Run the ignored tests
run: cargo test -- --ignored
- name: Run the doc tests
run: cargo test --doc
The --all-features enables the feature flags, and the separate jobs run the different test kinds.
Complete Example Session
// ============================================
// PART 1: THE LIBRARY
// ============================================
// src/lib.rs
pub struct Calculator {
memory: f64,
}
impl Calculator {
pub fn new() -> Self {
Calculator { memory: 0.0 }
}
pub fn add(&mut self, value: f64) -> f64 {
self.memory += value;
self.memory
}
pub fn divide(&mut self, value: f64) -> Result<f64, String> {
if value == 0.0 {
return Err("the division by the zero".to_string());
}
self.memory /= value;
Ok(self.memory)
}
pub fn memory(&self) -> f64 {
self.memory
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds() {
let mut calc = Calculator::new();
assert_eq!(calc.add(5.0), 5.0);
}
}
// ============================================
// PART 2: THE BASIC INTEGRATION TEST
// ============================================
// tests/calculator_test.rs
use my_project::Calculator;
#[test]
fn it_adds_from_the_outside() {
let mut calc = Calculator::new();
assert_eq!(calc.add(5.0), 5.0);
assert_eq!(calc.memory(), 5.0);
}
#[test]
fn it_divides_from_the_outside() {
let mut calc = Calculator::new();
calc.add(10.0);
assert_eq!(calc.divide(2.0), Ok(5.0));
}
#[test]
fn it_rejects_the_zero_division() {
let mut calc = Calculator::new();
calc.add(10.0);
assert!(calc.divide(0.0).is_err());
}
// ============================================
// PART 3: THE SHARED UTILITY MODULE
// ============================================
// tests/common/mod.rs
use my_project::Calculator;
pub fn make_calculator() -> Calculator {
let mut calc = Calculator::new();
calc.add(100.0);
calc
}
pub fn assert_close(a: f64, b: f64) {
assert!(
(a - b).abs() < 1e-9,
"the expected {} but got {}",
b,
a,
);
}
// ============================================
// PART 4: THE TEST WITH THE SHARED UTILITY
// ============================================
// tests/shared_test.rs
mod common;
use common::{make_calculator, assert_close};
use my_project::Calculator;
#[test]
fn it_uses_the_shared() {
let mut calc = make_calculator();
calc.add(50.0);
assert_close(calc.memory(), 150.0);
}
#[test]
fn it_starts_from_the_new() {
let calc = Calculator::new();
assert_close(calc.memory(), 0.0);
}
// ============================================
// PART 5: THE SETUP PATTERN
// ============================================
use std::sync::Once;
static INIT: Once = Once::new();
fn setup() {
INIT.call_once(|| {
std::env::set_var("CALC_PRECISION", "high");
});
}
#[test]
fn it_uses_the_environment() {
setup();
assert_eq!(std::env::var("CALC_PRECISION").unwrap(), "high");
}
// ============================================
// PART 6: THE TEST CONTEXT
// ============================================
// tests/common/mod.rs
pub struct TestContext {
pub calc: Calculator,
}
impl TestContext {
pub fn new() -> Self {
let mut calc = Calculator::new();
calc.add(1000.0);
TestContext { calc }
}
}
// tests/context_test.rs
mod common;
use common::TestContext;
#[test]
fn it_uses_the_context() {
let mut context = TestContext::new();
context.calc.add(500.0);
assert_eq!(context.calc.memory(), 1500.0);
}
// ============================================
// PART 7: THE RESULT RETURN
// ============================================
// tests/result_test.rs
use my_project::Calculator;
#[test]
fn it_handles_the_result() -> Result<(), Box<dyn std::error::Error>> {
let mut calc = Calculator::new();
calc.add(10.0);
let result = calc.divide(2.0)?;
assert_eq!(result, 5.0);
Ok(())
}
// ============================================
// PART 8: THE IGNORED TEST
// ============================================
// tests/expensive_test.rs
use my_project::Calculator;
#[test]
#[ignore = "the expensive"]
fn it_is_the_expensive() {
let mut calc = Calculator::new();
for i in 0..1_000_000 {
calc.add(i as f64);
}
assert!(calc.memory() > 0.0);
}
// ============================================
// PART 9: THE COMMANDS
// ============================================
// cargo test → all the tests
// cargo test --test calculator_test → the specific file
// cargo test --test-threads=1 → the serial
// cargo test -- --ignored → the ignored
// cargo test --doc → the doc tests
// ============================================
// PART 10: THE COMPLETE PROJECT
// ============================================
// my_project/
// ├── src/
// │ └── lib.rs ← the Calculator + the unit tests
// ├── tests/
// │ ├── common/
// │ │ └── mod.rs ← the shared utilities
// │ ├── calculator_test.rs
// │ ├── shared_test.rs
// │ ├── context_test.rs
// │ ├── result_test.rs
// │ └── expensive_test.rs
// └── Cargo.toml
// The cargo test runs:
// 1. The unit tests in the src/
// 2. The integration tests in the tests/
// 3. The documentation tests in the doc comments
The ten parts show the library, the basic integration test, the shared utility module, the test with the shared utility, the setup pattern, the test context, the Result return, the ignored test, the commands, and the complete project.
Quick Reference
The Directory Structure
| The path | The purpose |
|---|---|
src/lib.rs | The library |
src/*.rs (the #[cfg(test)]) | The unit tests |
tests/*.rs | The integration tests |
tests/common/mod.rs | The shared utilities |
Cargo.toml | The project manifest |
The Test Attributes
| The attribute | The purpose |
|---|---|
#[test] | The test function |
#[tokio::test] | The async test |
#[ignore] | The skipped test |
#[should_panic] | The must panic |
The Commands
| The command | The purpose |
|---|---|
cargo test | All the tests |
cargo test --test name | The specific file |
cargo test --lib | The unit tests |
cargo test --doc | The doc tests |
cargo test -- --ignored | The ignored |
cargo test -- --test-threads=1 | The serial |
cargo test -- --nocapture | The show output |
The Setup Patterns
| The pattern | The use case |
|---|---|
The Once | The one-time global setup |
The TestContext | The per-test resource |
The Drop | The automatic cleanup |
The #[ignore] | The expensive tests |
The --test-threads=1 | The shared state |
The Shared Utilities
| The file | The import |
|---|---|
tests/common/mod.rs | mod common; |
| The public functions | use common::foo; |
| The public structs | use common::TestContext; |
Best Practices
✅ Do This:
// Use the tests/ directory for the integration
// tests/integration_test.rs // ✅
// Use the common/mod.rs for the shared utilities
// tests/common/mod.rs // ✅
// Use the descriptive names
fn it_returns_the_user_when_the_found() { ... } // ✅
// Use the Result return for the fallible
fn it_works() -> Result<(), Box<dyn Error>> { ... } // ✅
// Use the #[ignore] for the expensive
#[ignore = "the requires the database"] // ✅
// Use the --test-threads=1 for the shared state
// cargo test -- --test-threads=1 // ✅
// Use the TestContext for the setup and the cleanup
let context = TestContext::new(); // ✅
❌ Don’t Do This:
// Don't put the unit tests in the tests/ directory
// The tests/ is the integration, the src/ is the unit // ❌
// Don't use the private API in the integration
use my_project::internal_function; // the private // ❌
// Don't test the multiple concepts in one test
fn it_creates_and_sends_and_logs() { ... } // the unclear // ❌
// Don't forget the cleanup
let context = TestContext::new(); // the leak // ❌
// Don't run the expensive tests by default
#[test] fn it_is_the_slow() { ... } // the slow // ❌
// Don't share the state without the serial
// cargo test (the parallel) // ❌
// Don't use the unwrap in the integration without the message
let user = client.create().unwrap(); // the unclear // ❌
Common Pitfalls
| The pitfall | The why | The fix |
|---|---|---|
| The test cannot access the private | The external crate | Use the unit test |
| The import error | The missing mod common | Add the declaration |
| The shared state conflict | The parallel execution | Use the --test-threads=1 |
| The resource leak | The missing cleanup | Use the Drop |
| The test is the slow | The no #[ignore] | Add the attribute |
The common/mod.rs is treated as the test | The wrong filename | Use the mod.rs |
| The async test fails | The missing #[tokio::test] | Add the attribute |
Real-World Examples
1. The Basic Integration Test
// tests/calculator_test.rs
use my_project::Calculator;
#[test]
fn it_adds() { ... }
2. The Shared Module
// tests/common/mod.rs
pub fn make_calculator() -> Calculator { ... }
3. The Test with the Shared
mod common;
use common::make_calculator;
4. The Setup
static INIT: Once = Once::new();
INIT.call_once(|| { ... });
5. The Test Context
pub struct TestContext { pub calc: Calculator }
6. The Result Return
fn it_works() -> Result<(), Box<dyn Error>> { ... }
7. The Ignored
#[ignore = "the expensive"]
8. The Async
#[tokio::test]
async fn it_works() { ... }
9. The Specific File
cargo test --test calculator_test
10. The Complete
cargo test
cargo test --test calculator_test
cargo test -- --ignored
Visual
The Integration Test Organization
┌──────────────────────────────────────────────────────────────┐
│ THE INTEGRATION TEST ORGANIZATION │
│ │
│ my_project/ │
│ ├── src/ │
│ │ └── lib.rs │
│ │ ├── the public API │
│ │ └── #[cfg(test)] mod tests ← the unit tests │
│ │ │
│ ├── tests/ │
│ │ ├── common/ │
│ │ │ └── mod.rs ← the shared utilities │
│ │ ├── calculator_test.rs ← the crate 1 │
│ │ ├── shared_test.rs ← the crate 2 │
│ │ └── context_test.rs ← the crate 3 │
│ │ │
│ └── Cargo.toml │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Each .rs file in the tests/ is a separate crate. │ │
│ │ Each crate depends on the library. │ │
│ │ Each crate can only access the public API. │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
The Unit vs the Integration
┌──────────────────────────────────────────────────────────────┐
│ THE UNIT vs THE INTEGRATION │
│ │
│ THE UNIT TEST: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ The location: the src/ with the #[cfg(test)] │ │
│ │ The access: the private and the public │ │
│ │ The speed: the fast │ │
│ │ The scope: the function │ │
│ │ The count: the many │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ THE INTEGRATION TEST: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ The location: the tests/ directory │ │
│ │ The access: the public only │ │
│ │ The speed: the slower │ │
│ │ The scope: the public API │ │
│ │ The count: the fewer │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ The two are the complements. │
│ │
└──────────────────────────────────────────────────────────────┘
The Test Context
┌──────────────────────────────────────────────────────────────┐
│ THE TEST CONTEXT │
│ │
│ pub struct TestContext { │
│ pub client: Client, │
│ pub database: Database, │
│ } │
│ │
│ impl TestContext { │
│ pub async fn new() -> Self { │
│ let database = Database::connect().await; │
│ let client = Client::new(&database); │
│ TestContext { client, database } │
│ } │
│ } │
│ │
│ impl Drop for TestContext { │
│ fn drop(&mut self) { │
│ // the cleanup │
│ } │
│ } │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ The test: │ │
│ │ let context = TestContext::new().await; │ │
│ │ let user = context.client.create_user("Alice"); │ │
│ │ assert_eq!(user.name, "Alice"); │ │
│ │ // the Drop runs the cleanup │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
The Cargo Test Discovery
┌──────────────────────────────────────────────────────────────┐
│ THE CARGO TEST DISCOVERY │
│ │
│ cargo test │
│ │ │
│ ├── The unit tests │
│ │ The #[cfg(test)] modules in the src/ │
│ │ │
│ ├── The integration tests │
│ │ The .rs files in the tests/ │
│ │ Each file is the separate crate │
│ │ │
│ └── The documentation tests │
│ The code examples in the /// comments │
│ │
│ The output: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ running 3 tests (the src/lib.rs) │ │
│ │ test tests::it_adds ... ok │ │
│ │ │ │
│ │ running 4 tests (the tests/calculator_test.rs) │ │
│ │ test it_adds ... ok │ │
│ │ │ │
│ │ running 2 tests (the tests/shared_test.rs) │ │
│ │ test it_uses_the_shared ... ok │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
tests/ | The integration test directory |
| The separate crate | Each .rs file |
| The public API | The only access |
tests/common/mod.rs | The shared utilities |
cargo test | Runs all the tests |
cargo test --test name | The specific file |
cargo test -- --ignored | The ignored tests |
cargo test -- --test-threads=1 | The serial |
The TestContext | The setup and the cleanup |
The Once | The one-time setup |
Key takeaways:
- The integration tests live in the
tests/directory, and each file is a separate crate. The test uses the library as the external consumer, and it can only access the public API. The integration tests verify the public contract, and the unit tests verify the internals . - The
cargo testdiscovers the.rsfiles in thetests/directory. Thetests/common/mod.rsis the shared module, and thecargo testdoes not treat it as the separate test file. Thecargo test --test nameruns the specific file . - The integration tests use the same
#[test]and assertions. The difference is the scope: the integration test sees the public API, and the unit test sees the internals. Theuse my_project::functionimports the public function . - The setup and the teardown use the
Onceand theTestContext. TheOnceruns the one-time global setup, and theTestContextcreates the per-test resource and cleans up with theDrop. The RAII pattern ensures the cleanup even when the test panics . - The
#[ignore]skips the expensive tests. Thecargo testskips them, and thecargo test -- --ignoredruns them. The CI runs the ignored tests in the separate job . - The
--test-threads=1runs the tests serially. The integration tests that share the global state (the database, the file system) must run serially. The alternative is the separate resource per test . - The
Resultreturn allows the?operator. The integration test can return theResult<(), E>, and the?propagates the error. The test fails with the clear message when theResultis theErr. - The integration tests are the black-box, and the unit tests are the white-box. The two are the complements, and the
cargo testruns both. The integration tests are the fewer and the public-API-focused, and the unit tests are the many and the internal-focused .
Remember: The integration tests live in the tests/ directory, and each file is a separate crate that depends on the library. The integration tests can only access the public API, and they verify the library as the consumer uses it. The cargo test discovers the files, and the cargo test --test name runs the specific one. The tests/common/mod.rs is the shared utilities, and the TestContext is the setup and the cleanup. The result is the verified public contract, which is the foundation of the reliable library.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!