Rust from the ground up

Modules, crates and tests

Splitting a program into files that can see only what they should, the cargo commands that hold a project together, and the tests that live beside the code they check.

Part 11 of 12

Advanced16 min readBack to Courses

Modules are the walls of a program

A mod groups code and decides what the rest of the program may see. Everything inside is private to the module unless it is marked pub, and use brings a path into the current file: use crate::store::save;. Small programs feel over-organised with walls; the point of them is a year later, when nothing outside a module has grown a dependency on its private parts.

The file layout follows the same names: mod store; inside main.rs means store.rs sits next to it, and a folder with mod.rs, or store/mod.rs, holds a bigger one. crate is the root of the current program, crate::store::save is a path through it, and std:: is the standard library — the same rules, further out.

Cargo holds it together

A real project is a folder with a Cargo.toml — the name, the version, and the dependencies — and a src folder with main.rs in it. cargo run builds and runs, cargo build --release makes the optimised binary, cargo test runs the tests, and cargo check type-checks without producing a binary, which is the fast way to see whether an idea compiles.

Dependencies are one line each in Cargo.toml, and the crates come from a registry with a lock file that records the exact versions. The compiler is strict about which versions can be combined, which is why a Rust project you open in a year builds the way its lock file says.

Tests live next to the code

A test is a function marked #[test] that fails by panicking: assert_eq!(double(3), 6);. The usual place to put them is a mod tests at the bottom of the same file, marked #[cfg(test)] so it is not compiled into the release build — right next to the function it checks, where it is read.

cargo test runs them all and prints what failed and where. The habit that matters is small tests named after the behaviour, written while the function is fresh: a test is the only description of code that the compiler runs.

Not every test belongs in the file it checks. A tests folder at the top of a project holds integration tests, each compiled as its own crate that can use only the public API — exactly what a user of the library sees. Unit tests inside the module can reach private items; integration tests prove the public surface is enough. The split is a question about who the test is for: the maintainer, or the caller.

A module, a function, and its test

mod store {
    pub fn double(n: i32) -> i32 {
        n * 2
    }

    fn secret() -> i32 { 42 }  // private to the module
}

fn main() {
    println!("{}", store::double(21));
}

// cargo test runs these; the run button does not.
#[cfg(test)]
mod tests {
    use super::store::double;

    #[test]
    fn doubles_a_number() {
        assert_eq!(double(3), 6);
    }

    #[test]
    fn doubles_zero() {
        assert_eq!(double(0), 0);
    }
}

Walls, a path, and a test

Run it to see the module in use, then add a second function to the store module — triple, say — call it from main, and write a test for it beside the first two.

Output
Click "Run" to execute your code

This site runs one file, so the tests here are shown and explained rather than executed — cargo test on your machine is what runs them. Everything else in the example compiles and runs exactly as written.

Paths, privacy and re-exports

A path starts from crate (this program), super (the parent module), self (this module), or a name already in scope. Privacy is enforced along the path, not just at the end: a pub function inside a private module is still unreachable from outside, which is why pub mod and pub fn are two separate decisions.

pub use re-exports an item, giving callers a shorter path without making the whole layout public. pub(crate) opens an item to the rest of the program but not to the world, the right default for helpers several modules share. Tests live in a child module, so they can see their parent's private items without any pub at all.

What a good test checks

A test has three parts: arrange the inputs, call the thing, assert the result. assert_eq! compares values and prints both when they differ; assert! takes a condition. The name should state the behaviour — doubles_zero tells you what broke before you open the file.

Test the edges, not the happy path twice. Zero, one, the maximum, an empty collection, and a value that should fail are the cases that catch real mistakes. A test that repeats the implementation in its assertion only proves you typed the same thing twice.

Three testing slips. A function without #[test] is just an unused function — cargo test never runs it. #[cfg(test)] is what keeps the test module out of the release binary; leave it off and the tests ship. And tests run in parallel in one process, so a test that relies on another test running first fails when the order changes — each test should build its own world.

Try these next

  • Add a triple function to the store module, call it from main, and write a test for it beside the others.
  • Write a #[should_panic] test for a function that rejects an empty name, and check that the panic message says why.
  • Move a helper into a nested module and call it from a test with a super:: path, then shorten the path with use.

A conversion module with tests

Run it to see the module in use. Then make `rounded` public, call it from main, and explain why that widens the module's promise. Add a test for 100 C.

Output
Click "Run" to execute your code

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

What can the rest of the program see inside a module?