第 08 篇
模块与测试
拆分模块并写测试,解释 crate、self、super 路径、可见性、use 别名、宏和属性。
Hex4C59 10 分钟阅读 更新于 2026-10-17
编程·原创·实战教程
当一个程序包含输入、计算和文件读写时,把所有内容放在 main.rs 会越来越难修改。本章将温度转换逻辑放到独立模块,并写测试固定它的行为。
先确定本章的工作目录 #
本章的文件都属于下载包里的 rust-course/basics。在 rust-course 目录执行带 -p rust-basics 的命令;编辑文件时进入它的 basics 子目录。不要把两个目录的 Cargo.toml 混在一起:前者管理工作区,后者定义具体包。
我们会把温度换算函数放进库,让一个示例程序调用它,再分别写单元测试、集成测试和文档测试。学完后,你应该能从报错路径找到对应文件,并判断一段逻辑需要作为公开接口还是留在模块内部。
先区分 package、crate 和 module #
| 名称 | 本章中的意思 |
|---|---|
| package(包) | 由一个 Cargo.toml 管理的库、程序、示例等构建目标及其配置 |
| crate | 单独交给 Rust 编译器处理的一组代码;本章的库和示例程序分别是一个 crate |
| module(模块) | 在一个 crate 内把函数等名称分组,并规定其他代码能否访问它们 |
| workspace(工作区) | 由一个顶层清单统一管理的多个包 |
配套源码的顶层 Cargo.toml 是工作区。basics 和 taskbook 是两个包;basics/src/lib.rs 是库 crate 的入口;math 是库里的模块。
一个包可以同时有库和可执行程序。常见做法是把可复用、可测试的逻辑放进 lib.rs,main.rs 只负责接收输入和调用逻辑。后续的待办工具项目会采用这个结构。
看文件树,比记四个名词更直观 #
本章用到的部分结构是:
rust-course/
├── Cargo.toml
└── basics/
├── Cargo.toml
├── src/
│ ├── lib.rs
│ └── math.rs
├── examples/
│ └── ch08_modules.rs
└── tests/
└── conversion.rsmath.rs 是库的一部分。示例和集成测试则通过库的公开入口调用函数。Cargo 认识 src/lib.rs、examples/、tests/ 这些约定的位置;普通模块文件怎样连到库里,还要由 mod 声明。
这一章不用建立很多目录。一个函数、一个模块、几个检查已经足够看清组织方式。等不同职责确实需要分开时,再拆新模块。
从三个文件开始 #
以下文件都相对于配套源码的 basics 目录。Cargo.toml 已把包命名为 rust-basics,因此 Rust 代码中引用库时使用 rust_basics。连字符与下划线的区别容易在第一次写 use 时造成困惑。
src/lib.rs 的完整内容:
pub mod math;pub mod math; 声明一个公开模块,并让编译器从对应的 math.rs 或模块目录中读取代码。仅在硬盘上创建文件,不会自动让它成为模块。
src/math.rs 的完整内容:
/// 把摄氏温度转换为华氏温度。
///
/// ```
/// assert_eq!(rust_basics::math::celsius_to_fahrenheit(0.0), 32.0);
/// ```
pub fn celsius_to_fahrenheit(celsius: f64) -> f64 {
celsius * 1.8 + 32.0
}
#[cfg(test)]
mod tests {
use super::celsius_to_fahrenheit;
#[test]
fn freezing_point() {
assert!((celsius_to_fahrenheit(0.0) - 32.0).abs() < 1e-10);
}
#[test]
fn negative_temperature() {
assert!((celsius_to_fahrenheit(-40.0) + 40.0).abs() < 1e-10);
}
}pub fn 允许模块外的调用方访问这个函数。Rust 默认限制很多条目的可见性;需要成为外部接口的条目再显式公开。use 用来把已有路径中的名称引入当前位置,不会自动加载一个未声明的模块。
运行调用它的示例:
cargo run -p rust-basics --example ch08_modulesbasics/examples/ch08_modules.rs 的完整内容:
use rust_basics::math::celsius_to_fahrenheit;
fn main() {
let fahrenheit = celsius_to_fahrenheit(25.0);
println!("25 摄氏度 = {fahrenheit} 华氏度");
}预期输出 25 摄氏度 = 77 华氏度。这个示例依赖上面的库模块,不能单独复制到一个没有 rust_basics 库的新项目后直接运行。
如果想从头创建自己的库,先在工作区之外运行 cargo new temperature --lib --edition 2024,再按相同方式创建模块。此时包名是 temperature,调用路径也需要换成 temperature::math::...。
两个 pub 分别解决哪一层可见性 #
调用路径 rust_basics::math::celsius_to_fahrenheit 经过库、模块、函数三个位置。pub mod math 让外部看见模块;pub fn celsius_to_fahrenheit 让外部看见其中的函数。
可以在自己的练习副本里暂时去掉其中一个 pub,运行 cargo check -p rust-basics --example ch08_modules。去掉模块的 pub 会报告模块私有,去掉函数的 pub 会报告函数私有。然后恢复。这能帮助你区分“没有这个名字”和“有这个名字但我不能访问”。
use 只是缩短书写路径。若不写 use,示例仍能直接调用完整路径:
fn main() {
let value = rust_basics::math::celsius_to_fahrenheit(25.0);
println!("{value}");
}这是替换 basics/examples/ch08_modules.rs 的完整版本,输出 77;它依赖本章的库,不能脱离这个包单独编译。
在下载包之外重建同样的结构 #
如果想检查自己是否真的会组织文件,在一个空的练习目录运行:
cargo new rust-basics --lib --edition 2024 --vcs none
cd rust-basics
mkdir examples tests将本文展示的 src/lib.rs、src/math.rs、examples/ch08_modules.rs 和 tests/conversion.rs 分别写到相应位置。cargo new 生成的默认 lib.rs 要由本文版本替换。这次包名也叫 rust-basics,因此代码里的 rust_basics 路径不用改。
在这个单包目录,可以省略 -p rust-basics:
cargo run --example ch08_modules
cargo test应输出 25 摄氏度等于 77 华氏度,并通过两个单元测试、一个集成测试、一个文档测试。若按照前文建立的是 temperature 包,则文档注释、示例、集成测试中的 rust_basics:: 都需要一起换成 temperature::。
单元测试:在模块附近检查行为 #
#[test] 标记测试函数。cargo test 会寻找并运行它们。#[cfg(test)] 表示这段代码只在构建测试时启用,因此 tests 模块不会作为正常业务接口发布。
use super::celsius_to_fahrenheit 从父模块引入待测函数。冻结点、负温度是不同的输入条件,能检查公式是否写对。
浮点数会有表示和运算误差。测试里用 (actual - expected).abs() < 1e-10 检查差值是否足够小,而不是对任意浮点运算都使用精确相等。这里的容差是为这些简单输入选择的测试标准,并非适合所有数值问题。
运行:
cargo test -p rust-basics --lib应看到两个测试通过。试着把公式中的 32.0 改成 30.0,重新运行,确认测试失败,再改回来。测试失败说明检查发挥了作用。
测试函数怎么报告失败 #
测试函数里的 assert!(条件) 要求条件为真;assert_eq!(实际值, 预期值) 要求两者相等。断言不成立会触发 panic,测试框架会把这个测试记为失败,再给出位置和相关信息。
测试名称应该让你在失败列表中能认出检查的行为。freezing_point 比 test1 更容易定位“冰点换算错了”。
只运行一个测试,可以在命令后加名称过滤:
cargo test -p rust-basics freezing_point这是名称过滤,不是文件路径。输出可能还列出别的测试目标,并显示其中没有匹配的测试。观察实际执行和通过的数量,不能只看命令没有报错。
修复测试时,先判断公式还是预期值有错。若正确结果应为 32,就不能为了让错误公式通过,把断言的 32 也改成 30。测试的目的,是保住你要求的行为。
集成测试:通过公开接口调用 #
basics/tests/conversion.rs 是完整的集成测试文件:
use rust_basics::math::celsius_to_fahrenheit;
#[test]
fn boiling_point_through_public_api() {
assert!((celsius_to_fahrenheit(100.0) - 212.0).abs() < 1e-10);
}它从库外部使用公开路径,不能依赖库的私有函数。运行 cargo test -p rust-basics --test conversion,应有一个测试通过。这类测试能发现库的公开入口无法使用,以及调用路径或可见性设置有误等问题。
两类测试该放在哪里 #
单元测试通常放在被测模块里,可以访问同一模块的私有实现;集成测试位于 tests/,作为外部使用方检查公开接口。它们观察的边界不同,不必为每个函数机械地写两份相同测试。
例如,温度公式可以就近检查不同输入;公开调用路径是否可用,则由这里的集成测试确认。到了待办工具,还会通过启动一个真正的可执行程序,检查两次运行之间的数据是否保留。
普通测试可能并行运行。不要让两个测试共同依赖一个会被修改的全局文件。第十二章的文件测试会给每次运行准备独立的临时目录。
文档测试:让示例也参与检查 #
/// 是文档注释,math.rs 中的 Rust 代码块也是一个可执行的使用示例。cargo test -p rust-basics --doc 会运行它。这样函数行为改变时,旧用法更容易被发现。
运行整个配套源码的验证命令:
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo test --workspace --all-targetscargo fmt 统一格式;--check 只检查是否已经符合格式。Clippy 检查常见问题;这里将警告当作失败。第一条 cargo test 包括库文档测试,第二条把示例等所有目标也纳入检查。构建一个示例不等于已经执行它的 main,观察输出仍需 cargo run --example ...。
从测试失败信息回到代码 #
遇到失败时,按下面的顺序处理:先找失败的测试名,再找断言所在行,最后比较实际输入和预期值。编译失败时测试尚未开始,应该先修语法、类型或可见性问题。
可以用 cargo test -p rust-basics --lib -- --nocapture 显示测试中的普通输出。第一个 -- 把后面的参数交给测试程序;它和 cargo run ... -- ... 的分隔作用相似。这是排查时的工具,断言仍应负责判断行为。
外部依赖先知道放在哪里 #
第三方库通常记录在 Cargo.toml 的 [dependencies] 下,由 Cargo 下载、构建和连接。一个常见安装命令是 cargo add 库名,其中“库名”必须换成实际依赖名称;这不是本章需要执行的步骤。
本章和前面的基础示例只使用标准库。能完成基础项目后,再按照需要引入命令行解析、序列化和网络库,并查阅对应版本的官方文档。可执行程序通常应保留 Cargo.lock,让依赖选择更容易复现。
语法细读:路径、可见性、宏和属性 #
本章语法细读里的每个 Rust 程序可以分别替换自己项目的 src/main.rs:正常例子运行 cargo run,故意失败的例子运行 cargo check。下载包另保存为独立文件,直接编译的方法见下载包里的 syntax/README.md。各个程序相互独立。
模块本身也是语言规则。文件放在哪个目录、名字通过哪条路径可见、以及哪段代码会参与编译,需要分别判断。
use 引入名字,不复制代码也不放宽访问限制 #
完整程序,下载包中为 syntax/syntax08-paths.rs:
mod tools {
pub(super) fn twice(value: i32) -> i32 {
self::inner::add(value, value)
}
mod inner {
pub(super) fn add(left: i32, right: i32) -> i32 {
left + right
}
}
}
use crate::tools::twice as double;
fn main() {
println!("{}", double(6));
}预期输出:
12crate:: 从当前 crate 根开始,self:: 从当前模块开始,super:: 从父模块开始。根部的 use ... as double 把一个可访问的函数用别名引入当前作用域;它不执行函数,也不把实现复制到这里。
pub(super) 表示向父模块范围开放。因此 tools 的父模块能调用 twice,tools 也能调用其子模块的 add。pub(crate) 把可见范围限制在当前 crate,普通 pub 则允许按所在模块路径继续向外公开。
以下完整程序故意无法编译,下载包中为 compile-fail/syntax08-private-child.rs:
mod tools {
pub mod inner {
fn hidden() {}
}
}
fn main() {
tools::inner::hidden();
}预期诊断包含 E0603。
子模块公开,不会使里面的所有函数自动公开。路径的各层与目标项都要允许这次访问;use 别名也不能让这次私有调用变合法。前面用文件组织模块的写法与这里的内联 mod { ... } 遵守同一套规则。
感叹号调用宏,属性作用在后面的项上 #
完整程序,下载包中为 syntax/syntax08-attributes-macros.rs:
#[derive(Debug)]
struct Point {
x: i32,
}
fn main() {
let point = Point { x: 3 };
let numbers = vec![point.x, 4];
assert_eq!(numbers.len(), 2);
println!("{point:?} / {numbers:?}");
}预期输出:
Point { x: 3 } / [3, 4]println!、vec!、assert_eq! 是宏调用;括号或方括号中的内容会按该宏的规则处理,不能仅因为写法像调用就认为它是普通函数。宏可以展开成表达式或其他代码结构,也不是“程序运行时再拼接一段源代码”。
#[derive(Debug)] 是外层属性,作用于后面的结构体定义,要求编译器生成调试输出所需的代码,也就是前面用过的 trait 实现;下一章会解释这种约定。#[cfg(test)] 用条件控制后面的项是否参与编译,#[test] 标记测试函数;它们不是放在函数前的一行注释,也不是每次调用函数都会执行的普通表达式。
阅读一个陌生片段时,先区分路径 ::、方法访问 .、宏调用 ! 和属性 #[...],再去找名字的定义和作用范围。
练习:增加一个测试 #
在 math.rs 的测试模块里,检查 25 摄氏度等于 77 华氏度。先写测试,再把公式改错,确认测试能发现问题,最后恢复正确公式。
参考答案:先自己完成再展开
把下面这个完整测试函数放到已有的 mod tests { ... } 内:
#[test]
fn room_temperature() {
let actual = celsius_to_fahrenheit(25.0);
assert!((actual - 77.0).abs() < 1e-10);
}运行 cargo test -p rust-basics --lib,新增后应有三个单元测试通过。如果是原始下载包,没有添加这个练习,库里仍是两个测试。
自查:三个常见接线问题 #
- 创建了
math.rs,但lib.rs没写mod math:编译器还没有从库入口找到这个模块,不会仅凭文件存在就把它加入库。 - 模块已声明,示例却无法访问函数:检查路径经过的模块和函数是否公开。
cargo test --all-targets通过,就认为文档代码也验证了:文档测试需要普通的cargo test或cargo test --doc。
试着不看答案重建四个文件。能从库外调用函数、能让错误公式触发测试失败、恢复后测试通过,本章的组织方式就已经用起来了。
官方延伸阅读 #
外部文档查阅日期:2026-10-13。
一起讨论
选中正文中的文字,可以针对这段内容发起讨论。
正在读取讨论…
在 GitHub 查看与管理讨论 ↗