24.3 Test Organization

Rust’s testing framework encourages separating tests based on their scope: unit tests and integration tests.

24.3.1 Unit Tests

Unit tests verify small, isolated components, typically individual functions or methods, including private ones. They are conventionally placed within the same source file as the code under test, inside a dedicated submodule named tests and annotated with #[cfg(test)].

// In src/lib.rs or src/my_module.rs
pub fn process_data(data: &[u8]) -> Result<String, &'static str> {
    if data.is_empty() {
        return Err("Input data cannot be empty");
    }
    internal_helper(data)
}

// Private helper function
fn internal_helper(data: &[u8]) -> Result<String, &'static str> {
    // ... complex logic ...
    Ok(format!("Processed {} bytes", data.len()))
}

// Unit tests are placed in a conditionally compiled submodule
#[cfg(test)] // Ensures this module is only compiled during `cargo test`
mod tests {
    use super::*; // Import items from the parent module (process_data, internal_helper)

    #[test]
    fn test_process_data_success() {
        let result = process_data(&[1, 2, 3]).unwrap();
        assert_eq!(result, "Processed 3 bytes");
    }

    #[test]
    fn test_process_data_empty() {
        let result = process_data(&[]);
        assert!(result.is_err());
        assert_eq!(result.unwrap_err(), "Input data cannot be empty");
    }

    #[test]
    fn test_internal_logic() {
        // Directly test the private helper function
        let result = internal_helper(&[10]).unwrap();
        assert!(result.contains("1 bytes")); // Example check
    }
}
  • #[cfg(test)]: This attribute ensures that the tests module and its contents are only included when compiling for tests (cargo test). This avoids including test code in release builds.
  • use super::*;: This imports all items (functions, types, etc.) from the parent module (super), making them available within the tests module.
  • Testing Private Items: Unit tests can directly access and test private functions and types within the same module (like internal_helper). This is useful for verifying internal implementation details or invariants that are not exposed publicly.

Cargo’s cargo new my_lib --lib command automatically generates a src/lib.rs file with this standard test module structure.

24.3.2 Integration Tests

Integration tests verify the public API of your library crate from an external perspective, mimicking how other crates would use it. They reside in a dedicated tests directory at the root of your project, alongside the src directory.

my_crate/
├── Cargo.toml
├── src/
│   └── lib.rs       // Contains process_data, internal_helper (private)
└── tests/           // Integration tests directory
    ├── common.rs    // Optional shared helper module
    └── api_usage.rs // An integration test file

Each .rs file within the tests directory is compiled by Cargo as a separate crate. This means each test file links against your library crate (my_crate in this case) as if it were an external dependency.

Example (tests/api_usage.rs):

// Import the library crate being tested
use my_crate; // Use the actual name defined in Cargo.toml

#[test]
fn test_public_api_call() {
    // Can only call public items (like process_data) from my_crate
    let result = my_crate::process_data(&[1, 2, 3, 4]).unwrap();
    assert_eq!(result, "Processed 4 bytes");

    // Attempting to call private items results in a compile-time error
    // let _ = my_crate::internal_helper(&[1]); // Error: function `internal_helper` is private
}

#[test]
fn test_empty_data_error() {
    let result = my_crate::process_data(&[]);
    assert!(result.is_err());
}
  • External Perspective: Integration tests can only access pub items (functions, structs, enums, modules) defined in your library crate. They cannot access private implementation details.
  • Separate Crates: Because each file in tests/ is a distinct crate, they are compiled independently. This ensures tests exercise the library’s public contract but means shared setup code requires specific handling.

Sharing Code Between Integration Tests

To share utility functions or setup logic across multiple integration test files, create a regular module file within the tests directory (e.g., tests/common.rs or tests/common/mod.rs). This file itself is not treated as a test crate. Other files in tests/ can then import items from it using mod common;.

// tests/common.rs
pub fn setup_environment() {
    // ... perform common setup actions ...
    println!("Common setup complete.");
}

pub fn create_test_data() -> Vec<u8> {
    vec![10, 20, 30]
}
// tests/another_integration_test.rs
use my_crate;
mod common; // Declare and import the common module

#[test]
fn test_with_shared_setup() {
    common::setup_environment();
    let data = common::create_test_data();
    let result = my_crate::process_data(&data).unwrap();
    assert!(result.contains("3 bytes"));
}

Integration Tests for Binary Crates

Integration tests are primarily designed for library crates (--lib). If your project is a binary crate (src/main.rs only), the tests/ directory cannot directly call functions within src/main.rs because a binary doesn’t produce a linkable artifact in the same way a library does.

The recommended approach for testing binary applications is to structure the project as a workspace member or adopt a library/binary hybrid pattern:

  1. Extract the core logic from src/main.rs into src/lib.rs, exposing public functions.
  2. Keep src/main.rs minimal, mainly handling argument parsing and calling the library’s public functions.
  3. Write integration tests in tests/ that target the public API defined in src/lib.rs.

This allows testing the core application logic independently of the command-line interface.