How to Use Test Fixtures and Setup/Teardown in Rust

Mark test functions with #[test], use #[cfg(test)] for modules, and call shared setup functions from a common module to manage fixtures.

When every test needs the same starting line

You are writing tests for a function that parses a configuration file. The first test checks valid JSON. The second test checks missing keys. The third test checks malformed syntax. Each test starts by creating a temporary directory, writing a file, and opening a reader. You copy the setup code three times. Then you change the file format. Now you have to update the setup in four places. In Python or JavaScript, you would reach for a setUp method that runs before each test and a tearDown method that runs after. Rust does not give you lifecycle hooks attached to test classes. Instead, you lean on modules, conditional compilation, and the Drop trait. The language treats setup and teardown as ordinary code that shares scope with the test itself.

The fixture pattern in plain words

A test fixture is just the initial state your code needs to run. In Rust, you build fixtures the same way you build any other value: you allocate it, use it, and let the compiler clean it up. The difference is how you guarantee cleanup. Rust uses a pattern called RAII, which stands for Resource Acquisition Is Initialization. The moment a variable is created, its resources are acquired. The moment the variable leaves scope, its resources are released. You do not schedule cleanup. You attach it to the lifetime of the data.

Think of it like a library book with a built-in return receipt. When you check out the book, the receipt is attached. When you leave the library, the receipt automatically triggers the return process. You do not need to remember to hand it to a clerk. The system guarantees it happens. In Rust, the Drop trait is that receipt. You implement Drop on a struct that holds your test resources. When the test function ends, whether it passes, fails, or panics, the struct goes out of scope and Drop runs. The cleanup is tied to memory management, not to a test framework scheduler.

A minimal setup and teardown

Start with a shared module for initialization logic and a wrapper struct for cleanup. Keep the test functions focused on assertions.

// src/lib.rs
pub fn parse_config(content: &str) -> Result<String, &str> {
    if content.contains("valid") {
        Ok(String::from("parsed"))
    } else {
        Err("missing required field")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // Shared setup lives in a submodule to keep individual tests readable
    mod common {
        pub fn create_test_payload() -> String {
            // Simulate expensive initialization like connecting to a mock server
            String::from("valid configuration data")
        }
    }

    // Wrapper struct to handle automatic cleanup when the test ends
    struct TestFixture {
        payload: String,
    }

    impl Drop for TestFixture {
        fn drop(&mut self) {
            // Cleanup runs automatically when the fixture goes out of scope
            // This executes even if the test panics or asserts fail
            println!("Fixture cleaned up");
        }
    }

    impl TestFixture {
        fn new() -> Self {
            Self {
                payload: common::create_test_payload(),
            }
        }
    }

    #[test]
    fn test_valid_config() {
        let fixture = TestFixture::new();
        let result = parse_config(&fixture.payload);
        assert!(result.is_ok());
        // Drop runs here automatically when the function returns
    }

    #[test]
    fn test_invalid_config() {
        let fixture = TestFixture::new();
        let result = parse_config("broken data");
        assert!(result.is_err());
    }
}

The #[cfg(test)] attribute tells the compiler to only include this module when you run cargo test. It disappears from release builds, keeping your production binary lean. The common submodule holds initialization logic that multiple tests can reuse. The TestFixture struct owns the data and implements Drop to handle teardown. You instantiate it at the top of the test, use it, and let scope handle the rest.

How the compiler and runtime handle it

When you run cargo test, the compiler strips every #[cfg(test)] block from the final binary. It then compiles each #[test] function as an independent entry point. The test runner spawns threads and executes these functions in parallel by default. Parallel execution is fast, but it means you cannot rely on execution order. Test A does not run before Test B. Each test must be self-contained.

At runtime, the test runner calls your function. The function body creates the TestFixture. The Drop implementation is registered with the compiler's drop glue. When the function returns, the compiler inserts a call to drop() before unwinding the stack. If an assert! fails, the test panics. The panic unwinds the stack, which still triggers drop(). Teardown is guaranteed because it is tied to stack frames, not to success paths.

Convention aside: the Rust community expects Drop implementations to never panic. If cleanup fails, you swallow the error or log it. Panicking in Drop causes a double panic, which aborts the process and hides the original test failure. Use let _ = cleanup_operation(); to discard errors safely.

Realistic example: temporary directories

File system tests are the most common place where fixtures matter. You need a temporary directory, you write files into it, you run your code, and you delete the directory. A custom wrapper makes this safe and repeatable.

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::PathBuf;

    // Wrapper that owns a temporary directory and deletes it on drop
    struct TempDirFixture {
        path: PathBuf,
    }

    impl TempDirFixture {
        fn new(name: &str) -> Self {
            let path = PathBuf::from(format!("/tmp/test_{}", name));
            // Create the directory immediately during initialization
            let _ = fs::create_dir_all(&path);
            Self { path }
        }

        // Helper to write a file inside the fixture directory
        fn write_file(&self, filename: &str, content: &str) {
            let file_path = self.path.join(filename);
            let _ = fs::write(&file_path, content);
        }
    }

    impl Drop for TempDirFixture {
        fn drop(&mut self) {
            // Remove the directory and all contents when the test finishes
            // Swallow errors to prevent double panics during cleanup
            let _ = fs::remove_dir_all(&self.path);
        }
    }

    #[test]
    fn test_file_reader() {
        let dir = TempDirFixture::new("reader_test");
        dir.write_file("data.txt", "hello world");
        let content = fs::read_to_string(dir.path.join("data.txt")).unwrap();
        assert_eq!(content, "hello world");
        // Directory is automatically deleted when `dir` goes out of scope
    }
}

This pattern scales to network mocks, database connections, and in-memory caches. You wrap the resource, implement Drop, and expose helper methods for test assertions. The test function stays focused on behavior, not boilerplate.

Pitfalls and compiler traps

Rust's strictness catches setup mistakes early, but it requires you to think about ownership and mutability. If you try to mutate a fixture without marking it as mutable, the compiler rejects you with E0596 (cannot borrow as mutable, as it is not declared mutable). Add let mut fixture = ... to fix it. If you move a value out of a borrowed fixture, you get E0507 (cannot move out of borrowed content). Clone the data or change the signature to take ownership.

Shared state across tests is the most common trap. You might be tempted to declare a static mut variable for a database connection pool. That is unsafe and causes data races when tests run in parallel. The compiler will block you with E0133 (dereference of raw pointer requires unsafe) if you try to access it without an unsafe block. Even inside unsafe, parallel tests will corrupt the state. Isolate each test with its own fixture, or use a synchronization primitive like std::sync::Mutex wrapped in a lazy_static or std::sync::OnceLock.

Another trap is forgetting that #[cfg(test)] modules are compiled separately from your main code. If you reference a private function from a test module without re-exporting it, you get E0603 (item is private). Mark the function pub(crate) or move the test into the same module as the function. Convention aside: the community prefers co-located tests. Place #[cfg(test)] modules at the bottom of the source file they test. It keeps the implementation and its verification side by side, reducing import friction.

Choosing your setup strategy

Use a local let binding when the setup is a single line and teardown is trivial. Use a dedicated TestFixture struct with Drop when you need guaranteed cleanup on panic or complex resource allocation. Use a shared mod common when multiple test modules need the same initialization logic. Use external crates like tempfile or mockall when you need cross-platform temporary directories or automatic mock generation. Reach for #[serial_test] or std::sync::Mutex when tests modify shared global state and run in parallel. Trust the borrow checker. It usually has a point.

Where to go next