Modules and Testing
Learning Objectives
- You can organize code into modules for better structure.
- You understand the relationship between files and modules.
- You know how to control visibility with
pub. - You can use external crates as dependencies.
- You can write and run unit tests for your code.
- You understand integration tests and when to use them.
- You can write documentation tests that stay up-to-date.
The Module System
As programs grow, organizing code becomes important. Rust’s module system helps you split code into logical units, manage visibility, and keep related functionality together.
Creating Modules
Modules are created with the mod keyword:
This creates a module tree:
crate
└── front_of_house
├── hosting
│ ├── add_to_waitlist
│ └── seat_at_table
└── serving
├── take_order
└── serve_order
Note: Everything in Rust is private by default. Parent modules cannot access private items in child modules, and child modules cannot access private items in parent modules.
Public vs Private
Use the pub keyword to make items public:
Both the module (pub mod hosting) and the function (pub fn add_to_waitlist) need to be public.
The use Keyword
Typing full paths gets tedious. The use keyword brings items into scope:
You can also use as to rename imports:
Organizing Code Across Files
As your program grows, you’ll want to split modules into separate files.
File Structure and Modules
Let’s organize our coffee shop code. Here’s the structure we want:
coffee_shop/
├── Cargo.toml
└── src/
├── main.rs
├── menu.rs
├── orders.rs
└── payments.rs
src/main.rs:
mod menu;
mod orders;
mod payments;
fn main() {
println!("Welcome to Rust Coffee Shop!");
menu::display_menu();
let order = orders::create_order("Latte");
orders::process_order(&order);
payments::process_payment(4.00);
}
src/menu.rs:
pub fn display_menu() {
println!("=== Menu ===");
println!("1. Espresso - 2.50 euros");
println!("2. Latte - 4.00 euros");
println!("3. Cappuccino - 3.50 euros");
}
pub fn get_price(item: &str) -> Option<f64> {
match item {
"Espresso" => Some(2.50),
"Latte" => Some(4.00),
"Cappuccino" => Some(3.50),
_ => None,
}
}
src/orders.rs:
pub struct Order {
item: String,
quantity: u32,
}
pub fn create_order(item: &str) -> Order {
Order {
item: String::from(item),
quantity: 1,
}
}
pub fn process_order(order: &Order) {
println!("Processing order: {} x{}", order.item, order.quantity);
}
src/payments.rs:
pub fn process_payment(amount: f64) {
println!("Processing payment of {:.2} euros", amount);
println!("Payment successful!");
}
The mod keyword in main.rs tells Rust to look for those modules in separate files.
If you’re creating a library crate, the main file is src/lib.rs instead of src/main.rs. The module structure works the same way.
Nested Modules in Files
You can also create subdirectories for nested modules.
src/
├── main.rs
├── front_of_house.rs
└── front_of_house/
├── hosting.rs
└── serving.rs
src/front_of_house.rs:
pub mod hosting;
pub mod serving;
src/front_of_house/hosting.rs:
pub fn add_to_waitlist() {
println!("Adding to waitlist");
}
src/front_of_house/serving.rs:
pub fn take_order() {
println!("Taking order");
}
Writing Tests
Rust has excellent built-in testing support. Tests are functions marked with the #[test] attribute.
Basic Test Structure
Run tests with:
cargo test
The
#[cfg(test)]annotation tells Rust to compile and run the test code only when runningcargo test, not when building the regular binary.
The Assert Macros
Rust provides several assert macros:
Testing for Panics
Sometimes you want to verify that code panics in certain situations:
Testing with Result
Tests can return Result<(), E> instead of panicking:
Using Result in tests is convenient because you can use the ? operator. The test fails if the function returns Err.
Unit Tests
Unit tests test individual units of code in isolation. They typically live in the same file as the code they test, inside a tests module.
Unit tests can test private functions because they’re in the same module:
Integration Tests
Integration tests test your library from the outside, as if you were a user. They go in a separate tests directory.
coffee_shop/
├── Cargo.toml
├── src/
│ ├── lib.rs
│ └── main.rs
└── tests/
├── integration_test.rs
└── common/
└── mod.rs
src/lib.rs:
pub fn create_order(item: &str, quantity: i32) -> String {
format!("Order: {} x{}", item, quantity)
}
pub fn calculate_total(items: &[(f64, i32)]) -> f64 {
items.iter()
.map(|(price, quantity)| price * *quantity as f64)
.sum()
}
pub struct Menu;
impl Menu {
pub fn get_price(item: &str) -> Option<f64> {
match item {
"Espresso" => Some(2.50),
"Latte" => Some(4.00),
"Cappuccino" => Some(3.50),
_ => None,
}
}
}
tests/integration_test.rs:
use coffee_shop::{create_order, calculate_total, Menu};
#[test]
fn test_create_order() {
let order = create_order("Latte", 2);
assert_eq!(order, "Order: Latte x2");
}
#[test]
fn test_calculate_total() {
let items = vec![
(2.50, 2), // 2 Espressos
(4.00, 1), // 1 Latte
];
let total = calculate_total(&items);
assert_eq!(total, 9.00);
}
#[test]
fn test_menu_known_item() {
assert_eq!(Menu::get_price("Espresso"), Some(2.50));
}
#[test]
fn test_menu_unknown_item() {
assert_eq!(Menu::get_price("Tea"), None);
}
#[test]
fn test_complete_order_flow() {
// Test the complete flow
let item = "Latte";
let quantity = 3;
// Get price from menu
let price = Menu::get_price(item).expect("Item should be on menu");
// Create order
let order = create_order(item, quantity);
assert!(order.contains("Latte"));
assert!(order.contains("3"));
// Calculate total
let total = calculate_total(&[(price, quantity)]);
assert_eq!(total, 12.00);
}
Run integration tests with:
cargo test
Or run only integration tests:
cargo test --test integration_test
Common Test Utilities
You can create shared test utilities in tests/common/:
tests/common/mod.rs:
pub fn setup_test_menu() -> Vec<(&'static str, f64)> {
vec![
("Espresso", 2.50),
("Latte", 4.00),
("Cappuccino", 3.50),
]
}
pub fn assert_price_in_range(price: f64, min: f64, max: f64) {
assert!(
price >= min && price <= max,
"Price {} not in range {}-{}",
price, min, max
);
}
tests/integration_test.rs:
mod common;
use coffee_shop::Menu;
#[test]
fn test_all_prices_reasonable() {
let menu = common::setup_test_menu();
for (item, expected_price) in menu {
let price = Menu::get_price(item).expect("Item should exist");
assert_eq!(price, expected_price);
common::assert_price_in_range(price, 1.0, 10.0);
}
}
Note: Files in
tests/common/are not treated as integration test files because they’re in a subdirectory. This is the convention for shared test code.
Documentation tests
Rust can also run code examples in your documentation comments as tests. This ensures your examples stay up-to-date and correct. For example, if your project is called aalto_rust_opencs, you can write a public library function (to lib.rs) with a testable doc comment like this:
/// Adds two numbers together.
/// # Example
/// ```
/// use aalto_rust_opencs::add;
/// let sum = add(2, 3);
/// assert_eq!(sum, 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}Run documentation tests with:
cargo test --docThe output would be similar to the following:
cargo test --doc
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.01s
Doc-tests aalto_rust_opencs
running 1 test
test src/lib.rs - add (line 3) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.25sGenerate documentation with:
cargo doc --openNote: Documentation tests are compiled as separate test programs, so they need to include the necessary imports. For library crates, examples automatically have access to the public API.
Summary
In this chapter, we learned how to organize and test Rust projects:
- Modules organize code with the
modkeyword - Visibility is controlled with
pub- everything is private by default - The use keyword brings items into scope
- pub use re-exports items for a cleaner public API
- Files and modules correspond -
mod menu;looks formenu.rs - Nested modules can use directories with a
.rsfile ormod.rs - Binary and library crates live in
src/main.rsandsrc/lib.rs - Dependencies are added in
Cargo.tomland come from crates.io - Unit tests live alongside code in
#[cfg(test)]modules - Integration tests go in the
tests/directory - Documentation tests in doc comments ensure examples stay current
- cargo test runs all tests by default