Rust Integration Tests: tests/ Directory

Rust distinguishes between two testing strategies with clear physical boundaries. Unit tests live inside your `src/` directory, typically in the same file as the code they test, wrapped in a...

Key Insights

  • Integration tests in Rust’s tests/ directory treat your crate as an external dependency, testing only your public API and catching issues that unit tests miss
  • Each file in tests/ compiles as a separate crate—use tests/common/mod.rs (not tests/common.rs) for shared helpers to avoid Cargo treating it as a test suite
  • Binary crates can’t be directly integration tested; keep main.rs thin and move logic to lib.rs for proper testability

Introduction to Integration Tests in Rust

Rust distinguishes between two testing strategies with clear physical boundaries. Unit tests live inside your src/ directory, typically in the same file as the code they test, wrapped in a #[cfg(test)] module. Integration tests live in a completely separate tests/ directory at your project root.

This separation isn’t just organizational—it’s semantic. Integration tests compile as external crates that depend on your library. They can only access your public API, exactly like any other downstream consumer. This constraint is the point: integration tests verify that your public interface works correctly when used as intended.

Unit tests answer “does this function work?” Integration tests answer “does this crate work when someone uses it?”

Setting Up the tests/ Directory

Cargo handles integration test discovery automatically. Create a tests/ directory at your project root (sibling to src/), and any .rs file you place there becomes a test suite.

my_project/
├── Cargo.toml
├── src/
│   └── lib.rs
└── tests/
    ├── api_tests.rs
    └── persistence_tests.rs

Each file in tests/ compiles as its own crate. This means:

  • No #[cfg(test)] attribute needed—the entire file is test code
  • Each file can have its own dependencies (though they share Cargo.toml)
  • Tests in different files run in parallel by default
  • Compilation happens separately, which can slow builds but improves isolation

Cargo compiles api_tests.rs and persistence_tests.rs as two independent test binaries. When you run cargo test, both execute (along with unit tests and doc tests).

Writing Your First Integration Test

Integration tests import your crate by name, just like any external user would. The crate name comes from your Cargo.toml, with hyphens converted to underscores.

Given this library code:

// src/lib.rs
pub struct Calculator {
    precision: u32,
}

impl Calculator {
    pub fn new(precision: u32) -> Self {
        Self { precision }
    }

    pub fn add(&self, a: f64, b: f64) -> f64 {
        let result = a + b;
        self.round(result)
    }

    fn round(&self, value: f64) -> f64 {
        let factor = 10_f64.powi(self.precision as i32);
        (value * factor).round() / factor
    }
}

Your integration test imports and uses the public API:

// tests/calculator_tests.rs
use my_calculator::Calculator;

#[test]
fn calculator_adds_with_precision() {
    let calc = Calculator::new(2);
    
    let result = calc.add(0.1, 0.2);
    
    assert_eq!(result, 0.30);
}

#[test]
fn calculator_rounds_to_specified_precision() {
    let calc = Calculator::new(1);
    
    let result = calc.add(1.234, 2.789);
    
    assert_eq!(result, 4.0);
}

Notice that round() isn’t accessible—it’s private. Integration tests enforce your API boundaries. If you find yourself wanting to test round() directly, that’s a signal: either make it public (if it’s genuinely part of your API) or test it through unit tests in src/lib.rs.

Organizing Tests with Submodules

As your test suite grows, you’ll want shared utilities: test fixtures, mock builders, assertion helpers. The naive approach creates a problem.

tests/
├── common.rs      # BAD: Cargo treats this as a test suite
├── api_tests.rs
└── persistence_tests.rs

Cargo will try to run common.rs as tests, producing confusing output about “0 tests” or errors if there’s no test function.

The solution: use the older module convention with a directory and mod.rs:

tests/
├── common/
│   └── mod.rs     # GOOD: Not treated as a test suite
├── api_tests.rs
└── persistence_tests.rs

Now common is a module, not a test file. Put your helpers there:

// tests/common/mod.rs
use my_calculator::Calculator;

pub struct TestContext {
    pub calc: Calculator,
    pub expected_precision: u32,
}

impl TestContext {
    pub fn new() -> Self {
        Self {
            calc: Calculator::new(2),
            expected_precision: 2,
        }
    }

    pub fn with_precision(precision: u32) -> Self {
        Self {
            calc: Calculator::new(precision),
            expected_precision: precision,
        }
    }
}

pub fn assert_approximately_equal(actual: f64, expected: f64, epsilon: f64) {
    let diff = (actual - expected).abs();
    assert!(
        diff < epsilon,
        "Values not approximately equal: {} vs {} (diff: {})",
        actual,
        expected,
        diff
    );
}

Import the module in your test files:

// tests/api_tests.rs
mod common;

use my_calculator::Calculator;
use common::{TestContext, assert_approximately_equal};

#[test]
fn test_with_shared_context() {
    let ctx = TestContext::new();
    
    let result = ctx.calc.add(1.0, 2.0);
    
    assert_eq!(result, 3.0);
}

#[test]
fn test_with_custom_assertion() {
    let calc = Calculator::new(10);
    
    let result = calc.add(0.1, 0.2);
    
    assert_approximately_equal(result, 0.3, 0.0000000001);
}

The mod common; declaration tells Rust to look for common/mod.rs (or common.rs, but we’re avoiding that). Each test file that needs helpers includes this declaration.

Running Integration Tests

The cargo test command runs everything: unit tests, integration tests, and doc tests. You can narrow scope with flags.

# Run all tests
cargo test

# Run only integration tests
cargo test --test '*'

# Run a specific integration test file
cargo test --test api_tests

# Run a specific test function across all test types
cargo test calculator_adds

# Run a specific test in a specific file
cargo test --test api_tests calculator_adds

# Control parallelism (useful for tests with shared resources)
cargo test -- --test-threads=1

# Show output from passing tests (normally suppressed)
cargo test -- --nocapture

Sample output from cargo test:

   Compiling my_calculator v0.1.0 (/path/to/project)
    Finished test [unoptimized + debuginfo] target(s) in 0.54s
     Running unittests src/lib.rs (target/debug/deps/my_calculator-abc123)

running 2 tests
test tests::internal_round_works ... ok
test tests::internal_precision_stored ... ok

test result: ok. 2 passed; 0 failed; 0 ignored

     Running tests/api_tests.rs (target/debug/deps/api_tests-def456)

running 2 tests
test test_with_shared_context ... ok
test test_with_custom_assertion ... ok

test result: ok. 2 passed; 0 failed; 0 ignored

     Running tests/persistence_tests.rs (target/debug/deps/persistence_tests-ghi789)

running 1 test
test saves_and_loads ... ok

test result: ok. 1 passed; 0 failed; 0 ignored

Each Running line indicates a separate test binary. Integration test files compile independently, which is why large test suites can slow down your build.

Binary Crates and Integration Testing

Here’s a limitation that trips up many Rust developers: you cannot write integration tests for binary crates. If your project only has src/main.rs and no src/lib.rs, the tests/ directory has nothing to import.

// src/main.rs - This cannot be tested from tests/
fn main() {
    let config = parse_args();
    let result = process(config);
    println!("{}", result);
}

fn parse_args() -> Config { /* ... */ }
fn process(config: Config) -> String { /* ... */ }

The solution is the thin binary pattern: move your logic to lib.rs and keep main.rs as a minimal entry point.

// src/lib.rs - All the testable logic lives here
pub struct Config {
    pub input: String,
    pub verbose: bool,
}

pub fn parse_args(args: &[String]) -> Config {
    Config {
        input: args.get(1).cloned().unwrap_or_default(),
        verbose: args.contains(&"--verbose".to_string()),
    }
}

pub fn process(config: &Config) -> String {
    if config.verbose {
        format!("Processing: {}", config.input)
    } else {
        config.input.to_uppercase()
    }
}
// src/main.rs - Thin wrapper, minimal logic
use my_cli::*;

fn main() {
    let args: Vec<String> = std::env::args().collect();
    let config = parse_args(&args);
    let result = process(&config);
    println!("{}", result);
}

Now integration tests can import my_cli and test parse_args and process directly. The only untested code is the trivial glue in main().

Best Practices and Common Patterns

Test public contracts, not implementation. Integration tests should verify behavior, not internal structure. If refactoring internals breaks your integration tests, they’re testing the wrong thing.

Name tests descriptively. Test names appear in output and should describe the scenario: user_creation_fails_with_duplicate_email beats test_user_1.

Use integration tests for workflows. Unit tests verify individual functions. Integration tests verify that multiple components work together correctly.

#[test]
fn complete_order_workflow() {
    let store = Store::new();
    let user = store.create_user("alice@example.com").unwrap();
    let product = store.add_product("Widget", 29_99).unwrap();
    
    let order = store.create_order(&user, &[product.id]).unwrap();
    store.process_payment(&order, &mock_payment()).unwrap();
    
    let completed = store.get_order(order.id).unwrap();
    assert_eq!(completed.status, OrderStatus::Paid);
}

Keep integration tests focused. Each test should verify one behavior. Multiple assertions are fine if they all relate to the same behavior.

Consider test execution time. Integration tests often involve I/O, network calls, or database operations. Use --test-threads=1 when tests share resources, or design tests to use isolated resources.

Don’t duplicate unit tests. If a function is thoroughly tested in src/, you don’t need to re-test every edge case in tests/. Integration tests should cover the integration, not repeat coverage.

The tests/ directory is one of Rust’s cleaner testing conventions. Use it to verify your crate works as advertised to external consumers—nothing more, nothing less.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.