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 thetests
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 thetests
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:
- Extract the core logic from
src/main.rs
intosrc/lib.rs
, exposing public functions. - Keep
src/main.rs
minimal, mainly handling argument parsing and calling the library’s public functions. - Write integration tests in
tests/
that target the public API defined insrc/lib.rs
.
This allows testing the core application logic independently of the command-line interface.