Integration Testing
Test Isolation
Test isolation guarantees that every test in a suite runs in a self-contained environment where its outcome never depends on whether other tests run before it, after it, or concurrently alongside it on another thread.
Shared State (Flaky / Race Conditions):
Test A --- writes ---> /tmp/app_test.db <--- truncates --- Test B (COLLISION!)
Test A --- binds ----> 127.0.0.1:8080 <--- binds ------- Test B (AddrInUse!)
Isolated State (Deterministic & Parallel-Safe):
Test A --- writes ---> /tmp/test_pid912_id0/app.db (Private Directory)
Test B --- writes ---> /tmp/test_pid912_id1/app.db (Private Directory)
Test A --- binds ----> 127.0.0.1:0 -> port 54101 (OS Ephemeral Port)
Test B --- binds ----> 127.0.0.1:0 -> port 54102 (OS Ephemeral Port)
Common Sources of Test Interference and Their Fixes
Because cargo test executes tests in parallel across multiple threads inside the same process, any shared mutable resource causes flaky failures:
| Shared Resource | Symptom When Run in Parallel | Isolation Solution |
|---|---|---|
| File System Paths | NotFound, AlreadyExists, or corrupted file contents |
Create a unique temporary directory per test using an atomic counter + PID and clean it up via Drop. |
| Network Ports | ErrorKind::AddrInUse on TcpListener::bind("127.0.0.1:8080") |
Bind to port 0 ("127.0.0.1:0") and read the assigned port via .local_addr()?.port(). |
| Environment Variables | Data races (unsafe in Rust 2024) or wrong config values read by sibling threads |
Pass configuration structs explicitly into functions instead of reading std::env::var deep inside library code. |
Working Directory (CWD) |
Relative paths resolve to the wrong folder when another thread calls set_current_dir |
Never call env::set_current_dir in parallel tests; pass explicit base Path arguments or Command::current_dir. |
Global static State |
Counts or caches accumulate across tests | Scope state to an instance struct created fresh inside each test. |
Building an Isolated Temporary Workspace per Test
Combining std::process::id(), an AtomicUsize counter, and a Drop guard gives every test thread its own isolated directory on disk that is automatically deleted even if an assertion panics:
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process;
use std::sync::atomic::{AtomicUsize, Ordering};
static TEST_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0);
pub struct IsolatedDir {
path: PathBuf,
}
impl IsolatedDir {
pub fn new(prefix: &str) -> io::Result<Self> {
let id = TEST_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir_name = format!("{}_{}_{}", prefix, process::id(), id);
let path = env::temp_dir().join(dir_name);
fs::create_dir_all(&path)?;
Ok(Self { path })
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for IsolatedDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
fn main() -> io::Result<()> {
let dir_a = IsolatedDir::new("test_iso")?;
let dir_b = IsolatedDir::new("test_iso")?;
// Both tests can write to the exact same relative filename "state.json" without colliding
let file_a = dir_a.path().join("state.json");
let file_b = dir_b.path().join("state.json");
fs::write(&file_a, r#"{"test": "A"}"#)?;
fs::write(&file_b, r#"{"test": "B"}"#)?;
assert_ne!(dir_a.path(), dir_b.path());
assert_eq!(fs::read_to_string(&file_a)?, r#"{"test": "A"}"#);
assert_eq!(fs::read_to_string(&file_b)?, r#"{"test": "B"}"#);
let saved_path_a = dir_a.path().to_path_buf();
drop(dir_a);
drop(dir_b);
println!("Isolated directories cleaned up on drop: {}", !saved_path_a.exists());
Ok(())
}
Isolated directories cleaned up on drop: true