← Back to Rust series
🦀
Modern Rust
Modern · Prerequisite: error 1-4 · Modern · Prerequisite: lesson 19 · Modern · Prerequisite: lesson 20

19–21. The Module System, Cargo, crates.io · Unit Tests, Integration Tests, doctests · Threads, Channels, Arc/Mutex

Once a Rust project grows you split code into modules and pull in external crates. This lesson covers the `mod` keyword and the file-split convention, `pub` visibility, `use` paths, declaring dependencies in Cargo.toml, and how to find and add packages from crates.io. Rust ships its test runner inside cargo. Add `#[test]` to a function and `cargo test` finds and runs it. This lesson covers unit tests (in a `mod tests` block), integration tests (under `tests/`), and doctests that automatically run code blocks in /// comments. Rust calls its model "fearless concurrency" because the borrow checker prevents data races at compile time even across threads. This lesson covers std::thread, mpsc channels for thread-to-thread messages, and the Arc<Mutex<T>> pattern when you actually need shared state.

RustmoduleCargocratecrates.iopubusetestcargo testdoctestassert_eqTDDthreadconcurrencyArcMutexchannelmpsc
Duration
⏱ ~1.5 hours + ~1.5 hours + ~2 hours
Level
📊 Intermediate
Prerequisite
🎯 Error 1-4
OUTCOME
Once a Rust project grows you split code into modules and pull in external crates. This lesson covers the `mod` keyword and the file-split convention, `pub` visibility, `use` paths, declaring dependencies in Cargo.toml, and how to find and add packages from crates.io. / Rust ships its test runner inside cargo. Add `#[test]` to a function and `cargo test` finds and runs it. This lesson covers unit tests (in a `mod tests` block), integration tests (under `tests/`), and doctests that automatically run code blocks in /// comments. / Rust calls its model "fearless concurrency" because the borrow checker prevents data races at compile time even across threads. This lesson covers std::thread, mpsc channels for thread-to-thread messages, and the Arc<Mutex<T>> pattern when you actually need shared state.

What you'll learn

  • 1Split code into modules with `mod`
  • 2Control visibility with `pub`
  • 3Shorten paths with `use`
  • 4Add external crates in Cargo.toml's [dependencies]
  • 5Use cargo add / cargo search / cargo doc for package workflow
  • 6Write `#[test]` functions and run them with `cargo test`
  • 7Follow the `mod tests` convention for unit tests
  • 8Write integration tests under `tests/`
  • 9Use `#[should_panic]` to verify a function panics
  • 10Run code in /// comments as doctests
  • 11Spawn threads with `std::thread::spawn` and join them
  • 12Move values into a thread via a `move` closure
  • 13Pass data between threads with mpsc channels
  • 14Mutate shared state safely with `Arc<Mutex<T>>`
  • 15Understand the role of the Send / Sync traits

19. The Module System, Cargo, crates.io

Once a Rust project grows you split code into modules and pull in external crates. This lesson covers the `mod` keyword and the file-split convention, `pub` visibility, `use` paths, declaring dependencies in Cargo.toml, and how to find and add packages from crates.io.

Overview

main.rs scaling past 100 lines turns into a mess. Rust's one-file-equals-one-module convention makes splitting natural without ceremony.

Core Concepts

1) crate vs. module

  • **crate** — one Cargo.toml = one package = one build unit
  • **module** — namespace unit inside a crate
  • Binary crate root = `src/main.rs`, library crate root = `src/lib.rs`

2) mod and file layout

rust
// src/main.rs
mod math;       // looks for math.rs or math/mod.rs

fn main() {
    println!("{}", math::add(2, 3));
}

// src/math.rs
pub fn add(a: i32, b: i32) -> i32 { a + b }

3) pub — visibility

  • Default = **private** (same-module only)
  • `pub` — exported
  • `pub(crate)` — exported within the crate
  • `pub(super)` — exported to parent module

4) use — path shortening

rust
use std::collections::HashMap;
use std::io::{self, Read};       // self = io itself, Read = trait
use crate::math::add as plus;     // alias

5) Cargo.toml dependencies

toml
[dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
thiserror = "1"

Hands-on Examples

Sample directory layout:

bash
src/
├── main.rs
├── config.rs
└── api/
    ├── mod.rs       (or src/api.rs)
    ├── user.rs
    └── post.rs
rust
// src/main.rs
mod config;
mod api;

fn main() {
    let cfg = config::load();
    api::user::create(&cfg);
}

// src/api/mod.rs (or src/api.rs + src/api/ folder)
pub mod user;
pub mod post;

// src/api/user.rs
use crate::config::Config;
pub fn create(cfg: &Config) { /* ... */ }

Add an external crate and use it:

bash
cargo add serde --features derive
cargo add chrono
cargo search regex      # search crates.io
rust
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug)]
struct User { name: String, age: u32 }

fn main() {
    let u = User { name: "Alice".into(), age: 30 };
    let json = serde_json::to_string(&u).unwrap();
    println!("{}", json);
}

Common Mistakes

Q. mod vs. use — what's the difference?

A. **mod** brings a module into existence, **use** shortens a path to an existing module. Without `mod foo;` you can't `use crate::foo`.

Q. Without `pub`, can I use it from the same file?

A. Yes — within the same module (= same file or same `mod` block) you can access private items. **External modules need pub.**

Q. Should Cargo.lock be committed?

A. **Binary crates yes, library crates no.** Binaries want reproducible builds; libraries have no inherent lockfile because users have their own.

Recap

  • crate = build unit, module = namespace unit
  • mod declares, file/folder convention auto-maps
  • pub / pub(crate) / pub(super) for visibility tiers
  • use shortens paths, Cargo.toml manages deps, cargo add for quick install

Try It Yourself

  1. Split main.rs and math.rs, call math::add from main
  2. Add src/api/ with user.rs / post.rs and use them from main
  3. `cargo add chrono` and print the current time

20. Unit Tests, Integration Tests, doctests

Rust ships its test runner inside cargo. Add `#[test]` to a function and `cargo test` finds and runs it. This lesson covers unit tests (in a `mod tests` block), integration tests (under `tests/`), and doctests that automatically run code blocks in /// comments.

Overview

Having tests built into cargo is a big deal — no "which test framework should I use" decisions, and every new project can adopt testing from day one.

Core Concepts

1) Unit tests in the same file

rust
pub fn add(a: i32, b: i32) -> i32 { a + b }

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn add_works() {
        assert_eq!(add(2, 3), 5);
    }

    #[test]
    fn add_negative() {
        assert_eq!(add(-1, 1), 0);
    }
}
  • `#[cfg(test)]` — only compiled in test builds
  • `use super::*` — imports private items from the parent module
  • Only `#[test]`-marked fns get executed by cargo test

2) Integration tests in tests/

Test the public API only — same paths a downstream user would use.

rust
// tests/api.rs
use my_crate::add;

#[test]
fn integration_add() {
    assert_eq!(add(10, 20), 30);
}

3) Common assertion macros

MacroUse
assert!(expr)expr must be true
assert_eq!(a, b)a == b
assert_ne!(a, b)a != b
#[should_panic]test must panic to pass
#[ignore]skip by default (cargo test -- --ignored)

4) Doctests — run code inside /// comments

rust
/// Add two numbers.
///
/// # Examples
///
/// ```
/// assert_eq!(my_crate::add(2, 3), 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 { a + b }

Hands-on Examples

Result-returning test and should_panic:

rust
pub fn divide(a: i32, b: i32) -> i32 {
    if b == 0 { panic!("div by zero"); }
    a / b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn ok_case() -> Result<(), String> {
        if divide(10, 2) == 5 { Ok(()) } else { Err("expected 5".into()) }
    }

    #[test]
    #[should_panic(expected = "div by zero")]
    fn panic_case() { divide(10, 0); }
}

Useful invocations:

bash
cargo test                  # everything
cargo test add_              # name contains add_
cargo test -- --nocapture    # show println! output
cargo test --doc             # doctests only
cargo test -- --ignored      # run ignored ones

Common Mistakes

Q. println! doesn't show up in cargo test

A. By design — passing tests hide output. Use `cargo test -- --nocapture` to see it.

Q. mod tests vs. tests/?

A. **mod tests** = same crate, has access to private items. **tests/** = external view, public API only. Use both as needed.

Q. Tests run too slow

A. cargo test parallelizes by default. Force serial with `cargo test -- --test-threads=1`. Or mark slow tests `#[ignore]` and run on demand.

Recap

  • cargo test auto-discovers #[test] functions
  • mod tests + #[cfg(test)] is the unit-test convention
  • tests/ folder is for integration tests (public API)
  • Code blocks in /// comments run as doctests
  • Useful: should_panic / ignore / assert_eq

Try It Yourself

  1. Add 3+ unit tests in a `mod tests` block for an existing function
  2. Add one integration test under `tests/api.rs`
  3. Embed a doctest in /// comments and verify `cargo test --doc` passes

21. Threads, Channels, Arc/Mutex

Rust calls its model "fearless concurrency" because the borrow checker prevents data races at compile time even across threads. This lesson covers std::thread, mpsc channels for thread-to-thread messages, and the Arc<Mutex<T>> pattern when you actually need shared state.

Overview

Rust's concurrency promise is **"safe even when you make mistakes."** The same borrowing rule that prevented data races in single-threaded code scales out to threads, so the compiler catches missing locks before runtime. Debugging time goes way down.

Core Concepts

1) Spawning a thread

rust
use std::thread;
use std::time::Duration;

fn main() {
    let h = thread::spawn(|| {
        for i in 1..=3 {
            println!("child {}", i);
            thread::sleep(Duration::from_millis(50));
        }
    });
    for i in 1..=3 {
        println!("main {}", i);
        thread::sleep(Duration::from_millis(50));
    }
    h.join().unwrap();
}

2) move closures to transfer values

If a closure borrows from outside, the compiler can't prove the thread won't outlive the data. `move` transfers ownership in.

rust
let v = vec![1, 2, 3];
let h = thread::spawn(move || println!("{:?}", v));
// v has moved into the thread
h.join().unwrap();

3) mpsc channels — message passing

Standard multi-producer, single-consumer channel. Multiple senders, one receiver.

4) Arc<Mutex<T>> for shared mutable state

  • **Arc<T>** — atomic reference count, shareable across threads
  • **Mutex<T>** — only one thread can write at a time (runtime lock)
  • **Arc<Mutex<T>>** combo — multiple threads share + mutate safely

5) Send / Sync traits

TraitMeaning
SendSafe to transfer ownership across threads
SyncSafe to share references across threads

Auto-derived for nearly all standard types. Rc and RefCell are **not** Send/Sync — passing one across threads is a compile error, and Arc/Mutex are the substitute.

Hands-on Examples

Send 1~10 from a worker, sum in main:

rust
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel::<i32>();
    thread::spawn(move || {
        for i in 1..=10 { tx.send(i).unwrap(); }
    });
    let sum: i32 = rx.iter().sum();
    println!("sum = {}", sum); // 55
}

Increment a counter safely from many threads:

rust
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut hs = vec![];
    for _ in 0..10 {
        let c = Arc::clone(&counter);
        hs.push(thread::spawn(move || {
            let mut n = c.lock().unwrap();
            *n += 1;
        }));
    }
    for h in hs { h.join().unwrap(); }
    println!("counter = {}", *counter.lock().unwrap()); // 10
}

Common Mistakes

Q. Can I pass Rc across threads?

A. No — Rc's reference count is non-atomic and races. Use Arc instead (atomic). The compiler enforces this via Send.

Q. When does a Mutex unlock?

A. When the **MutexGuard you got from `lock()` is dropped** — usually at end of scope. To release earlier use `drop(guard)` or narrow the scope with an explicit block.

Q. async/await as a replacement?

A. CPU work fits threads; IO-bound work fits async. The next lesson covers async I/O.

Recap

  • Spawn with `thread::spawn` + move closure
  • Pass data via mpsc channels
  • Shared mutable state = Arc<Mutex<T>>
  • Send/Sync traits encode thread safety at compile time

Try It Yourself

  1. Four threads sum 1..25, 26..50, 51..75, 76..100; aggregate via mpsc in main
  2. Make `Arc<Mutex<Vec<i32>>>` and have several threads push into it safely
  3. Try passing Rc into `thread::spawn` to read the exact compiler error
Example code / lecture materials

All lecture materials and example code are openly available on GitHub.

View on GitHub ↗

Comments 0

No comments yet. Be the first.

Only members can comment.

Comments with ads, abuse, personal information or illegal content are hidden or removed. Comments are shown with your nickname. Privacy Policy