Rust Test Organization
Testing is a crucial part of software development, and Rust provides a robust testing framework built right into the language. However, as your projects grow in complexity, organizing your tests becomes increasingly important. Well-organized tests are easier to maintain, understand, and expand upon.
Introduction to Test Organization in Rust
When you first start writing tests in Rust, you might place them directly alongside your code using the #[test]
attribute. This works well for small projects, but as your codebase grows, maintaining hundreds of scattered tests becomes challenging.
Rust offers several patterns and tools for organizing your tests effectively:
- Unit Tests: Tests placed in the same file as the code they're testing
- Integration Tests: Tests in a separate
tests
directory - Test Modules: Organizing tests within modules
- Test Utilities: Shared testing code
Let's explore each of these approaches in detail.
Unit Tests with #[cfg(test)]
Unit tests in Rust are typically placed in the same file as the code they test. This is made possible by using the #[cfg(test)]
attribute, which tells the compiler to include the code only when running tests.
Basic Example
// src/lib.rs
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
assert_eq!(add(-1, 1), 0);
assert_eq!(add(0, 0), 0);
}
}
When you run cargo test
, Rust compiles and executes the test function, producing output like:
running 1 test
test tests::test_add ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Organizing Multiple Unit Tests
As you add more tests, organize them by functionality:
// src/lib.rs
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
pub fn subtract(a: i32, b: i32) -> i32 {
a - b
}
#[cfg(test)]
mod tests {
use super::*;
mod arithmetic {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn test_subtract() {
assert_eq!(subtract(5, 2), 3);
}
}
}
This creates a clearer organization with tests grouped by functionality. Running tests now shows:
running 2 tests
test tests::arithmetic::test_add ... ok
test tests::arithmetic::test_subtract ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Integration Tests in a Separate Directory
Integration tests verify that multiple parts of your library work together correctly. Unlike unit tests, integration tests are placed in a separate tests
directory at the same level as src
.
Directory Structure
my_project/
├── Cargo.toml
├── src/
│ └── lib.rs
└── tests/
├── integration_test.rs
└── common/
└── mod.rs
Basic Integration Test Example
// tests/integration_test.rs
use my_project; // This imports your crate
#[test]
fn test_add_and_double() {
let sum = my_project::add(2, 3);
let doubled = sum * 2;
assert_eq!(doubled, 10);
}
Each file in the tests
directory is compiled as a separate crate. Running cargo test
now shows:
running 1 test
test test_add_and_double ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Sharing Test Utilities
For shared test functionality, create a common
module:
// tests/common/mod.rs
pub fn setup() -> String {
// Common setup code
String::from("test environment")
}
Use it in your tests:
// tests/another_test.rs
mod common;
#[test]
fn test_with_common_setup() {
let env = common::setup();
assert_eq!(env, "test environment");
}
Test Organization Patterns
Here are some recommended patterns for organizing your tests:
1. Mirror Your Source Structure
src/
├── models/
│ ├── user.rs
│ └── product.rs
└── utils/
└── math.rs
tests/
├── models/
│ ├── user_test.rs
│ └── product_test.rs
└── utils/
└── math_test.rs
This pattern makes it easy to find tests related to specific components.
2. Group by Functionality
#[cfg(test)]
mod tests {
use super::*;
mod creation {
use super::*;
// Tests for object creation
}
mod validation {
use super::*;
// Tests for validation logic
}
mod conversion {
use super::*;
// Tests for type conversion
}
}
3. Test Tables for Similar Test Cases
#[test]
fn test_addition_cases() {
let test_cases = vec![
(1, 1, 2),
(5, -5, 0),
(100, 50, 150),
(-30, -20, -50),
];
for (a, b, expected) in test_cases {
assert_eq!(add(a, b), expected, "Testing add({}, {})", a, b);
}
}
Using Test Attributes for Organization
Rust provides several attributes to help organize and control test execution:
#[ignore]
Mark tests that are slow or resource-intensive:
#[test]
#[ignore]
fn expensive_test() {
// This test won't run unless specifically requested
}
Run ignored tests with:
cargo test -- --ignored
#[should_panic]
Test that a function panics under certain conditions:
#[test]
#[should_panic(expected = "division by zero")]
fn test_divide_by_zero() {
let _result = 5 / 0; // This should panic
}
Test Organization Workflow
Here's a visual representation of how different test types are organized:
Practical Example: Building a Math Library
Let's see a complete example of organizing tests for a simple math library:
// src/lib.rs
pub mod arithmetic {
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
pub fn subtract(a: i32, b: i32) -> i32 {
a - b
}
pub fn multiply(a: i32, b: i32) -> i32 {
a * b
}
pub fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
return Err(String::from("Division by zero"));
}
Ok(a / b)
}
}
#[cfg(test)]
mod unit_tests {
use super::arithmetic::*;
mod addition {
use super::*;
#[test]
fn test_positive_numbers() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn test_negative_numbers() {
assert_eq!(add(-2, -3), -5);
}
#[test]
fn test_mixed_numbers() {
assert_eq!(add(-2, 5), 3);
}
}
mod division {
use super::*;
#[test]
fn test_valid_division() {
assert_eq!(divide(10, 2), Ok(5));
}
#[test]
fn test_division_by_zero() {
assert_eq!(divide(10, 0), Err(String::from("Division by zero")));
}
}
}
And an integration test:
// tests/arithmetic_integration.rs
use math_library::arithmetic::{add, multiply};
#[test]
fn test_combined_operations() {
// Test that we can combine operations
let a = 5;
let b = 10;
let sum = add(a, b);
let product = multiply(sum, 2);
assert_eq!(product, 30);
}
Running Specific Tests
As your test suite grows, you'll often want to run only specific tests:
# Run all tests
cargo test
# Run tests with names containing "addition"
cargo test addition
# Run a specific test
cargo test test_positive_numbers
# Run tests in a specific module
cargo test unit_tests::addition
Best Practices for Test Organization
- Keep tests close to code: Unit tests should be near the code they test
- Group related tests: Use modules to organize tests by functionality
- Test file naming: Use clear naming conventions like
feature_test.rs
- Separate test utilities: Move common test code to shared modules
- Use descriptive test names: Names should describe what's being tested
- Independent tests: Each test should be able to run in isolation
- Balance between unit and integration tests: Use both for comprehensive coverage
Summary
Organizing your Rust tests effectively is crucial for maintaining a healthy, testable codebase. By leveraging Rust's built-in testing features—including test modules, integration tests, and test attributes—you can create a test structure that scales with your project.
Remember these key points:
- Use
#[cfg(test)]
modules for unit tests within source files - Create a
tests/
directory for integration tests - Organize tests into logical groups using nested modules
- Share common test functionality through modules
- Use test attributes to control test behavior
Additional Resources
Exercises
- Convert an existing project's tests to use nested test modules
- Create shared test utilities for database setup in integration tests
- Implement a test table pattern for a function with multiple edge cases
- Set up an integration test that verifies multiple components working together
- Use the
#[ignore]
attribute to mark resource-intensive tests and run them separately
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)