Best Practices for Organizing Tests in Rust Projects

Organize Rust tests by placing unit tests in the same file as the code they test using `#[cfg(test)]` modules, and integration tests in a separate `tests` directory.

When tests start fighting you

You wrote a function. You wrote a test. It passed. You wrote another function. You put the test right below it. Six months later, your lib.rs is four thousand lines long. Half the file is production code, half is test code. You want to run just the tests for the database module, but cargo test compiles the entire project and takes three minutes. You realize the test organization is fighting you.

Rust has a strict convention for where tests live. It splits testing into two buckets based on scope. Unit tests check a single function or module in isolation. They live right next to the code. Integration tests check how multiple parts work together through the public API. They live in a separate directory. This separation isn't just tradition. It changes how the compiler treats your code, how fast your tests run, and what code your tests can access.

The two buckets: unit and integration

Think of unit tests like checking the wiring inside a single lightbulb. You want to know if the filament works, regardless of the socket or the switch. You test the component alone.

Integration tests are like flipping the switch and seeing if the room lights up. You don't care about the filament. You care that the switch, the wiring, the bulb, and the power grid all cooperate to produce light. You test the system as a user would see it.

Rust enforces this distinction through file structure and compiler attributes. The compiler treats unit tests and integration tests as fundamentally different artifacts.

Unit tests live with the code

Unit tests go in the same file as the function they test. You wrap them in a module marked with #[cfg(test)]. This attribute tells the compiler to include the module only when you run tests.

pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

// #[cfg(test)] tells the compiler to skip this module during normal builds.
// This keeps test code out of your release binary.
#[cfg(test)]
mod tests {
    // Bring the parent scope into the test module so you can call `add`.
    use super::*;

    #[test]
    fn test_add() {
        assert_eq!(add(2, 2), 4);
    }
}

The #[cfg(test)] attribute is the key. When you run cargo build, the compiler sees the attribute, ignores the tests module entirely, and the test code never touches the binary. When you run cargo test, Cargo sets a configuration flag that makes #[cfg(test)] evaluate to true. The compiler includes the module, checks the test code, links it, and runs it.

This keeps your release binary small. It also prevents test dependencies from leaking into production. If you import a heavy mocking library inside a #[cfg(test)] module, that library never appears in your dependency tree for users of the crate.

Keep unit tests fast and private. If it touches the file system, it's not a unit test.

Integration tests live in the tests/ directory

Integration tests go in a tests/ directory at the root of the crate. Each file in tests/ becomes a separate binary. This means integration tests compile as if they were a different crate that depends on your library.

// tests/integration_test.rs
// Integration tests import the crate like an external user would.
// They cannot see private functions or fields.
use my_crate::add;

#[test]
fn test_add_via_public_api() {
    assert_eq!(add(5, 5), 10);
}

Because integration tests are separate binaries, they can only access the public API. If you try to call a private function from an integration test, the compiler stops you with E0603 (private function not accessible). This is a feature. Integration tests must use the public API. If you need to test internal logic, move that test to a unit test inside the module.

This separation catches issues where public types interact in unexpected ways. A unit test might pass because it calls a private helper directly, but the integration test fails because the public wrapper doesn't expose the helper correctly. Integration tests verify that the crate actually works for downstream users.

Integration tests are your safety net for the public API. If the integration test passes, your users are safe.

Realistic project structure

A mature Rust project uses both buckets. The structure looks like this:

my_crate/
├── Cargo.toml
├── src/
│   ├── lib.rs
│   └── math.rs
└── tests/
    ├── math_integration.rs
    └── common/
        └── mod.rs

The src/math.rs file contains the implementation and unit tests. The tests/math_integration.rs file contains integration tests. The tests/common/ directory holds shared setup code for integration tests.

Here is how the files connect:

// src/math.rs
pub fn multiply(a: i32, b: i32) -> i32 {
    a * b
}

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

    #[test]
    fn test_multiply() {
        assert_eq!(multiply(3, 4), 12);
    }
}
// src/lib.rs
pub mod math;
// tests/math_integration.rs
// Integration tests can share code via a `common` module.
mod common;

use my_crate::math;

#[test]
fn test_multiply_integration() {
    // Use the shared setup helper from common.
    let _env = common::setup_test_env();
    assert_eq!(math::multiply(3, 4), 12);
}
// tests/common/mod.rs
pub fn setup_test_env() -> TestEnv {
    TestEnv { initialized: true }
}

pub struct TestEnv {
    pub initialized: bool,
}

The tests/common/mod.rs pattern is the standard way to share setup code across integration tests. Since each file in tests/ is a separate binary, you can't just use a function from another test file. You have to declare a module. The convention is to put shared helpers in tests/common/mod.rs and import them with mod common; in each test file.

Convention aside: Name integration test files after the feature, not the module. tests/user_auth.rs is better than tests/auth.rs if auth is an internal module name. Integration tests verify behavior, not implementation details.

Dev-dependencies for test-only crates

You often need helper crates for testing that you don't want in production. Cargo.toml supports a [dev-dependencies] section for this.

[dev-dependencies]
tempfile = "3.8"
assert_cmd = "2.0"

Crates listed in [dev-dependencies] are available to unit tests and integration tests, but they are not included in the dependency graph for users of your crate. This is how you use tempfile for temporary directories or mockall for mocking without bloating your release binary.

If you forget to put a test helper in [dev-dependencies], the compiler complains that the crate is not found. Move it to the dev section and the error disappears.

Pitfalls and compiler errors

Organizing tests introduces specific traps. Avoid these common mistakes.

If you create a src/tests/ directory, Cargo ignores it. The compiler treats src/tests/ as a module, not an integration test directory. Your tests won't run. Always put tests/ at the project root, next to Cargo.toml. This is a silent failure. The build succeeds, but the tests never execute.

If you try to test a private function from an integration test, you get E0603. The fix is to move the test to a unit test inside the module, or make the function public if it should be part of the API. Don't mark functions pub(crate) just to test them. That leaks implementation details. Write a unit test instead.

Integration tests run in parallel by default. If two tests write to the same file or database, they race. You'll see flaky failures that disappear when you run tests one by one. Use the serial_test crate to force sequential execution for shared resources, or isolate resources per test. Parallel tests are fast, but shared state kills reliability.

Don't fight the compiler here. If integration tests can't see the function, the function is private for a reason.

Decision: when to use each test type

Use unit tests when you need to verify the internal logic of a function or module in isolation. Place them in the same file as the code, wrapped in a #[cfg(test)] module. This gives you access to private helpers and runs fast because the compiler doesn't need to rebuild the crate boundary.

Use integration tests when you want to verify that multiple modules work together through the public API. Place them in the tests/ directory. Each file compiles as a separate binary, which catches issues where public types interact in unexpected ways. This is the only way to test that your public interface actually works for downstream users.

Use doc tests when you want to demonstrate usage examples that double as executable tests. Place them in /// comments above functions or types. Run them with cargo test --doc. This keeps your documentation alive and proves the examples compile.

Use dev-dependencies for test-only crates like tempfile or assert_cmd. This keeps your production dependency tree clean while giving your tests the tools they need.

Match the test to the scope. Don't force a square peg into a round hole.

Where to go next