- 文档
- 教程
【免费下载链接】rust-by-example
Learn Rust with examples (Live code editor included)
测试是任何软件质量保障的基石,Rust 语言本身就对单元测试、集成测试与文档测试提供了“一等公民”般的原生支持。本文以 rust-by-example 仓库的 Cargo 测试章节 为核心骨架,结合仓库 Testing 章节 及其四个子章节,系统讲解测试在 Cargo 工程中的目录组织方式、cargo test的完整用法与输出解析、按名称过滤测试、以及 Cargo 并发执行测试带来的竞态风险与规避方案。读完本文,你将能够为自己的 Rust 工程搭建规范的测试体系,并熟练运用过滤、忽略、断言与文档测试等实战技巧。
Cargo 与测试:从生态到命令的一体化支持
在深入测试细节之前,先回顾 Cargo 在整个 Rust 生态中的定位。Cargo 章节 明确指出:cargo是 Rust 官方包管理工具,除了依赖管理与 crates.io(Rust 官方包仓库)集成外,它天然“感知”单元测试与基准测试(benchmarks),这意味着你不需要任何第三方测试框架,直接用cargo test即可运行工程内全部测试。
Cargo 对测试的原生支持体现在两个方面:
- 依赖管理层面:通过
[dev-dependencies]可以声明仅用于测试(或示例、基准)的依赖,这些依赖不会传播给依赖本包的其它包(详见后文与 dev-dependencies 章节); - 工程组织层面:Cargo 约定了一套标准目录布局(见 Conventions 章节),测试代码被安放在约定位置后,
cargo test会自动发现并执行它们。
测试的目录组织:单元测试进模块,集成测试进 tests/
rust-by-example 的 Cargo 测试章节 给出了测试代码的标准组织原则:
组织上,我们把单元测试放在它们所测试的模块内部,把集成测试放在独立的
tests/目录中。
一个典型的工程布局如下:
foo ├── Cargo.toml ├── src │ └── main.rs │ └── lib.rs └── tests ├── my_test.rs └── my_other_test.rs其中每个文件的职责清晰可辨:
src/main.rs、src/lib.rs:业务源码,单元测试以#[cfg(test)] mod tests的形式内嵌在被测模块内(详见 unit_testing.md);tests/目录下的每个文件都是一个独立的集成测试。
集成测试:以“外部调用者”身份验证公共接口
Integration testing 章节 对集成测试做了精确定义:集成测试独立于你的 crate 之外,只能像任何其它使用方代码一样调用它的公共接口,目的是验证库的多个部分能否协同工作。相比之下,单元测试一次只隔离测试一个模块,规模小且可以测试私有代码。
因此,Cargo 测试章节 特别强调:tests/目录中的每个文件都是一个独立的集成测试,即“把库当作被外部依赖 crate 调用时那样进行测试”。
一个完整的集成测试示例(crate 名为adder):
文件src/lib.rs:
// 在名为 `adder` 的 crate 中定义此函数 pub fn add(a: i32, b: i32) -> i32 { a + b }文件tests/integration_test.rs:
#[test] fn test_add() { assert_eq!(adder::add(3, 2), 5); }注意集成测试通过adder::add这样的 crate 路径调用公开 API,与外部使用者完全一致。
tests/目录中每个 Rust 源文件都会被编译成一个独立的 crate。若想在多个集成测试间共享公共代码(如环境准备逻辑),正确做法是创建一个tests/common/mod.rs模块:
文件tests/common/mod.rs:
pub fn setup() { // 一些初始化代码,比如创建所需文件/目录、启动服务器等 }文件tests/integration_test.rs:
// 导入 common 模块 mod common; #[test] fn test_add() { // 使用 common 中的代码 common::setup(); assert_eq!(adder::add(3, 2), 5); }此处有一条重要经验:把共享模块写成tests/common.rs虽然也能工作,但不推荐——因为测试运行器会把该文件当作一个测试 crate 对待并尝试运行其中的“测试”,造成干扰。写成tests/common/mod.rs则会被 Cargo 识别为共享模块而不会独立编译成测试二进制。
cargo test:一条命令运行全部测试
Cargo 为运行全部测试提供了最直接的入口,这也是 Cargo 测试章节 的核心命令:
$ cargo test以文档中的blah工程为例,典型输出如下:
$ cargo test Compiling blah v0.1.0 (file:///nobackup/blah) Finished dev [unoptimized + debuginfo] target(s) in 0.89 secs Running target/debug/deps/blah-d3b32b97275ec472 running 4 tests test test_bar ... ok test test_baz ... ok test test_foo_bar ... ok test test_foo ... ok test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out这段输出包含大量可解读的信息:
- 编译阶段:
Compiling与Finished dev [unoptimized + debuginfo]说明cargo test默认以 dev profile(未优化、含调试信息)编译测试; - 测试二进制:
Running target/debug/deps/blah-d3b32b97275ec472是被执行的测试可执行文件(名称带哈希以区分不同构建); - 逐条结果:
test <名称> ... ok列出每个测试的判定; - 汇总行:
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out依次给出通过数、失败数、忽略数(#[ignore])、测量数(基准测量)与过滤数(被名称过滤掉的测试数)。
从仓库的 unit_testing.md 可以看到,若测试失败,输出中会附上失败详情与断言信息:
$ cargo test running 2 tests test tests::test_bad_add ... FAILED test tests::test_add ... ok failures: ---- tests::test_bad_add stdout ---- thread 'tests::test_bad_add' panicked at 'assertion failed: `(left == right)` left: `-1`, right: `3`', src/lib.rs:21:8 note: Run with `RUST_BACKTRACE=1` for a backtrace. failures: tests::test_bad_add test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out按名称模式过滤测试
当工程测试较多时,只运行部分测试会更高效。Cargo 测试章节 演示了最简单的过滤方式:把名称片段作为参数传给cargo test,Cargo 会运行名称匹配该模式的所有测试。
$ cargo test test_foo$ cargo test test_foo Compiling blah v0.1.0 (file:///nobackup/blah) Finished dev [unoptimized + debuginfo] target(s) in 0.35 secs Running target/debug/deps/blah-d3b32b97275ec472 running 2 tests test test_foo ... ok test test_foo_bar ... ok test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out注意上例:尽管只指定了test_foo,但名为test_foo_bar的测试也被运行了——因为它是子串匹配。汇总行末尾的2 filtered out表明其余两个测试被过滤。
unit_testing.md 进一步展示了过滤的两种用法:
- 指定完整名称只运行单个测试:
$ cargo test test_any_panic running 1 test test tests::test_any_panic ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 3 filtered out- 指定名称片段批量运行相关测试(例如所有名字含
panic的测试):
$ cargo test panic running 3 tests test tests::test_any_panic ... ok test tests::test_specific_panic ... ok test tests::test_specific_panic_shorthand ... ok test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 1 filtered outcargo test还支持把参数传给测试运行器:例如cargo test -- --ignored专门运行被#[ignore]标记的测试(详见 unit_testing.md 的“Ignoring tests”小节)。
测试内容的三要素:断言、?与#[should_panic]
集成测试与单元测试都依托 unit_testing.md 讲解的断言体系。测试是验证非测试代码按预期工作的 Rust 函数:函数体通常先做一些准备(setup),运行被测代码,再断言结果是否符合预期。测试函数在发生 panic 时即失败,因此常用以下辅助宏:
assert!(expression):表达式求值为false时 panic;assert_eq!(left, right):左右表达式不等时 panic;assert_ne!(left, right):左右表达式相等时 panic。
配合#[cfg(test)](仅在测试构建下编译该模块)与#[test](标记测试函数)即可写出标准单元测试。一个值得注意的细节是:私有函数也可以被测试——同文件模块内的测试可以访问被测模块的私有项:
pub fn add(a: i32, b: i32) -> i32 { a + b } // 这是一个很糟糕的加法函数,其目的是在本例中失败。 #[allow(dead_code)] fn bad_add(a: i32, b: i32) -> i32 { a - b } #[cfg(test)] mod tests { // 注意这个惯用法:从外层(mod tests 的)作用域导入名字。 use super::*; #[test] fn test_add() { assert_eq!(add(1, 2), 3); } #[test] fn test_bad_add() { // 这个断言会触发并使测试失败。 assert_eq!(bad_add(1, 2), 3); } }让测试返回 Result,直接用?
从 Rust 2018 起,测试函数可以返回Result<()>,从而在测试体内直接使用?运算符,使测试更简洁:
fn sqrt(number: f64) -> Result<f64, String> { if number >= 0.0 { Ok(number.powf(0.5)) } else { Err("negative floats don't have square roots".to_owned()) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_sqrt() -> Result<(), String> { let x = 4.0; assert_eq!(sqrt(x)?.powf(2.0), x); Ok(()) } }断言 panic:#[should_panic]
对于特定条件下应当 panic的函数,使用#[should_panic]属性标记测试。该属性还接受可选参数expected =指定期望的 panic 消息文本——当函数可能以多种方式 panic 时,这能确保测试验证的是正确的那个 panic:
pub fn divide_non_zero_result(a: u32, b: u32) -> u32 { if b == 0 { panic!("Divide-by-zero error"); } else if a < b { panic!("Divide result is zero"); } a / b } #[cfg(test)] mod tests { use super::*; #[test] fn test_divide() { assert_eq!(divide_non_zero_result(10, 2), 5); } #[test] #[should_panic] fn test_any_panic() { divide_non_zero_result(1, 0); } #[test] #[should_panic(expected = "Divide result is zero")] fn test_specific_panic() { divide_non_zero_result(1, 10); } #[test] #[should_panic = "Divide result is zero"] // 这种写法同样有效 fn test_specific_panic_shorthand() { divide_non_zero_result(1, 10); } }Rust 还支持简写形式#[should_panic = "message"],它与#[should_panic(expected = "message")]完全等价;两种写法均合法,其中带expected =的写法更常用、也更显式。
忽略测试:#[ignore]
对暂时不需要执行的测试,可用#[ignore]属性将其排除在常规运行之外,之后再用cargo test -- --ignored专门运行它们:
pub fn add(a: i32, b: i32) -> i32 { a + b } #[cfg(test)] mod tests { use super::*; #[test] fn test_add() { assert_eq!(add(2, 2), 4); } #[test] fn test_add_hundred() { assert_eq!(add(100, 2), 102); assert_eq!(add(2, 100), 102); } #[test] #[ignore] fn ignored_test() { assert_eq!(add(0, 0), 0); } }$ cargo test running 3 tests test tests::ignored_test ... ignored test tests::test_add ... ok test tests::test_add_hundred ... ok test result: ok. 2 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out Doc-tests tmp-ignore running 0 tests test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out $ cargo test -- --ignored running 1 test test tests::ignored_test ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out从这两段输出可以看到一个有趣的细节:测试结果下方还有一行Doc-tests tmp-ignore—— 这正是cargo test顺带运行文档测试(doc-tests)的表现。
文档测试:让文档注释里的代码片段可编译、可运行
Rust 项目最主要的文档形式是对源码的注释标注,文档注释遵循 CommonMark Markdown 规范并支持代码块。Rust 负责校验这些代码块的正确性——它们会被编译并作为文档测试执行。这就是 doc_testing.md 的主题,也解释了为什么每次cargo test输出末尾都会出现Doc-tests <crate名>段落。
/// 第一行是描述函数的简短摘要。 /// /// 接下来的行提供详细文档。代码块以三个反引号开头,内部隐式包含 /// `fn main()` 和 `extern crate <cratename>`。 /// /// ``` /// let result = playground::add(2, 3); /// assert_eq!(result, 5); /// ``` pub fn add(a: i32, b: i32) -> i32 { a + b }文档测试随普通cargo test自动执行:
$ cargo test running 0 tests test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out Doc-tests playground running 3 tests test src/lib.rs - add (line 7) ... ok test src/lib.rs - div (line 21) ... ok test src/lib.rs - div (line 31) ... ok test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out文档测试存在一个常见痛点:示例代码常想使用?,但?要求函数返回Result,而文档代码块隐式的main返回单元类型,直接使用会编译失败。解决办法是用#前缀隐藏辅助行:编写一个隐藏的try_main() -> Result<(), ErrorType>,再在隐藏的main里unwrap它:
/// 在文档测试中使用隐藏的 `try_main`。 /// /// ``` /// # // 以 `#` 开头的行是隐藏行,但依然参与编译! /// # fn try_main() -> Result<(), String> { // 包裹文档中展示的函数体的行 /// let res = playground::try_div(10, 2)?; /// # Ok(()) // 从 try_main 返回 /// # } /// # fn main() { // 启动 main,将执行 unwrap() /// # try_main().unwrap(); // 调用 try_main 并 unwrap /// # // 以便出错时测试 panic /// # } /// ``` pub fn try_div(a: i32, b: i32) -> Result<i32, String> { if b == 0 { Err(String::from("Divide-by-zero")) } else { Ok(a / b) } }测试专用依赖:[dev-dependencies]
有时某些依赖仅测试(或示例、基准)需要,生产代码并不使用。dev_dependencies.md 指出:这类依赖应加入Cargo.toml的[dev-dependencies]段,且不会被传播给依赖本包的其它包。
一个典型例子是pretty_assertions——它扩展标准assert_eq!/assert_ne!宏,输出彩色差异对比。文件Cargo.toml:
# 标准 crate 数据在此省略 [dev-dependencies] pretty_assertions = "1"文件src/lib.rs:
pub fn add(a: i32, b: i32) -> i32 { a + b } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; // 仅测试用 crate,不能在非测试代码中使用 #[test] fn test_add() { assert_eq!(add(2, 3), 5); } }注意use pretty_assertions::assert_eq;只在#[cfg(test)] mod tests内生效,这正是[dev-dependencies]的核心语义——该依赖只在测试构建中存在,普通构建完全感知不到它。
并发执行的警告:测试之间可能互相竞争
Cargo 测试章节 在文末给出了一条重要告诫:
Cargo 可能并发运行多个测试,所以务必确保它们不会互相竞争(race)。
文档给出一个经典竞争示例:两个测试同时向同一个文件追加内容。尽管开发者意图是先写完Ferris五行、再写Corro五行:
#[cfg(test)] mod tests { // 导入必要的模块 use std::fs::OpenOptions; use std::io::Write; // 该测试向文件写入内容 #[test] fn test_file() { // 打开 ferris.txt;若不存在则创建它 let mut file = OpenOptions::new() .append(true) .create(true) .open("ferris.txt") .expect("Failed to open ferris.txt"); // 打印 "Ferris" 5 次 for _ in 0..5 { file.write_all("Ferris\n".as_bytes()) .expect("Could not write to ferris.txt"); } } // 该测试尝试向同一个文件写入内容 #[test] fn test_file_also() { // 打开 ferris.txt;若不存在则创建它 let mut file = OpenOptions::new() .append(true) .create(true) .open("ferris.txt") .expect("Failed to open ferris.txt"); // 打印 "Corro" 5 次 for _ in 0..5 { file.write_all("Corro\n".as_bytes()) .expect("Could not write to ferris.txt"); } } }期望的文件内容是:
$ cat ferris.txt Ferris Ferris Ferris Ferris Ferris Corro Corro Corro Corro Corro但实际写入ferris.txt的内容却是(两行交替穿插):
$ cargo test test_file && cat ferris.txt Corro Ferris Corro Ferris Corro Ferris Corro Ferris Corro Ferris这个例子生动揭示了并发执行的破坏力:两个测试进程交错执行write_all,破坏了文件内容的整体性。规避方案包括:让每个测试使用独立的输出文件(文件名可加入测试名或临时目录);或借助共享模块(如前面提到的tests/common/mod.rs)中的setup()做串行化与资源清理;或使用cargo test -- --test-threads=1之类的方式限制测试并发(具体选项以当前 Cargo/测试运行器版本为准)。核心原则始终是:测试之间不能共享可变的外部状态。
小结:一套完整可落地的 Rust 测试工作流
综合 Cargo 测试章节 与 Testing 章节 的全部内容,一个规范的 Rust 工程测试体系可以归纳为:
- 组织:单元测试内嵌于被测模块的
#[cfg(test)] mod tests中(可测私有函数);集成测试放在tests/目录(每个文件一个独立 crate,只走公共接口);共享测试代码放进tests/common/mod.rs; - 运行:
cargo test一条命令跑完全部单元、集成与文档测试;用cargo test <名称片段>过滤,用cargo test -- --ignored专门跑被忽略的测试; - 断言:熟练使用
assert!、assert_eq!、assert_ne!;让测试返回Result<()>以使用?;用#[should_panic(expected = "...")]验证特定 panic; - 依赖:仅测试用的依赖放入
[dev-dependencies],避免污染下游使用者; - 并发安全:时刻警惕 Cargo 并发执行测试的默认行为,避免测试之间因共享文件、端口等外部资源而发生竞争。
这套工作流完全内置于 Rust 官方工具链,无需任何第三方测试框架即可在任意 Cargo 工程中直接使用。
延伸阅读(仓库内相关章节)
- Testing 章节总览:三种测试风格(单元 / 文档 / 集成)与 dev-dependencies 的入口页;
- unit_testing.md:断言宏、
Result<()>测试、#[should_panic]、#[ignore]的完整代码示例; - integration_testing.md:
tests/目录语义与tests/common/mod.rs共享模块实践; - doc_testing.md:文档注释、代码块测试与隐藏行(
#)技巧; - dev_dependencies.md:测试专用依赖的声明与使用;
- conventions.md:Cargo 工程目录约定(
src/bin/多二进制、测试与示例布局); - deps.md:
Cargo.toml中[dependencies]的声明方式(crates.io、git、本地路径)。
- 文档
- 教程
【免费下载链接】rust-by-example
Learn Rust with examples (Live code editor included)
相关推荐
cargo test 完全指南:Cargo 的单元测试、集成测试与文档测试实战与底层原理
cargo test 完全指南:Cargo 的单元测试、集成测试与文档测试实战与底层原理 cargo test 是 Rust 包管理器 Cargo 中执行测试的
开发工具包管理器CLI构建工具从 `cargo test` 看 Cargo 的测试体系:单元测试、集成测试与文档测试全解析
从 cargo test 看 Cargo 的测试体系:单元测试、集成测试与文档测试全解析 cargo test 是 Cargo 提供的统一测试入口:一条命令即可
开发工具包管理器CLI构建工具react-bootstrap组件测试:单元测试与集成测试完整指南
react bootstrap组件测试:单元测试与集成测试完整指南 在React应用开发中,组件测试是确保UI稳定性和功能正确性的关键环节。react boot
前端UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考