Test Fixtures
A test fixture is a deterministic baseline state—such as pre-populated database records, a running mock server, or a configured domain object graph—that a test sets up before acting and tears down afterward. Unlike xUnit frameworks in other languages that rely on implicit beforeEach and afterEach lifecycle reflection, Rust expresses fixtures explicitly using helper constructors, builder structs, and RAII Drop guards.
Rust RAII Test Fixture Lifecycle:
#[test]
fn my_test() {
let ctx = TestContext::setup(); // <--- `beforeEach`: allocates & seeds state
let res = ctx.service.query(); // <--- Act & Assert (may panic on failure!)
assert!(res.is_ok());
} // <--- `afterEach`: `ctx.drop()` runs automatically on normal return OR unwind!
Why RAII Drop Beats Manual Teardown Functions
If you write a manual teardown call at the end of a test (setup(); run_test(); teardown();), any failed assert_eq! or ? early return in run_test() will unwind or exit the function before teardown() is ever reached, leaking temporary files, background child processes, or database schemas.
Placing teardown logic inside impl Drop for Fixture guarantees that cleanup runs even when the test panics.
use std::net::TcpListener;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::{self, JoinHandle};
pub struct MockServerFixture {
pub endpoint: String,
shutdown: Arc<AtomicBool>,
worker: Option<JoinHandle<()>>,
}
impl MockServerFixture {
pub fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind failed");
listener.set_nonblocking(true).expect("nonblocking failed");
let addr = listener.local_addr().unwrap();
let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_clone = Arc::clone(&shutdown);
let worker = thread::spawn(move || {
while !shutdown_clone.load(Ordering::Relaxed) {
if let Ok((_stream, _peer)) = listener.accept() {
break;
}
thread::yield_now();
}
});
Self {
endpoint: format!("http://{addr}"),
shutdown,
worker: Some(worker),
}
}
}
impl Drop for MockServerFixture {
fn drop(&mut self) {
// Signal the background worker to stop and join it cleanly
self.shutdown.store(true, Ordering::Relaxed);
if let Some(handle) = self.worker.take() {
let _ = handle.join();
}
}
}
fn main() {
{
let server = MockServerFixture::start();
assert!(server.endpoint.starts_with("http://127.0.0.1:"));
println!("Fixture running on dynamic endpoint");
} // `server` drops here and joins its background thread cleanly
println!("Fixture torn down cleanly");
}
Fixture running on dynamic endpoint
Fixture torn down cleanly
Expensive Read-Only Shared Fixtures with LazyLock
When a fixture is strictly read-only and expensive to compute (such as parsing a large JSON schema or compiling a static lookup table), you can initialize it once per test binary using std::sync::LazyLock:
use std::collections::HashMap;
use std::sync::LazyLock;
static FIXTURE_CODES: LazyLock<HashMap<&'static str, u16>> = LazyLock::new(|| {
HashMap::from([
("OK", 200),
("CREATED", 201),
("NOT_FOUND", 404),
])
});
fn main() {
assert_eq!(FIXTURE_CODES.get("OK"), Some(&200));
assert_eq!(FIXTURE_CODES.get("NOT_FOUND"), Some(&404));
println!("Shared LazyLock fixture loaded {} status codes", FIXTURE_CODES.len());
}
Shared LazyLock fixture loaded 3 status codes