本文接续上一篇 写给 Java 程序员的 Rust 入门,专门补一下测试这块。
一、Cargo test:测试就是一等公民
Java 那边的测试是靠 Maven 把 surefire 插件挂到 test 阶段才跑起来的,本质上是构建生命周期里的一站。Rust 这边完全不一样:测试是 Cargo 的内置能力,不需要装任何插件。
最常用的命令也就这么几个:
cargo test # 跑所有测试,相当于 mvn test
cargo test some_keyword # 跑名字里包含 some_keyword 的测试
cargo test -- --nocapture # 测试时让 println! 的输出也打出来
cargo test --release # 用 release 模式编译再测,慢但更接近线上
cargo test --doc # 只跑文档测试(doc test)
cargo test --tests # 只跑普通测试,不跑 doc test
我们注意到 cargo test 和 cargo build 走的是不同的 profile(test profile),编译产物也是分开放的,跟 main code 不冲突。
cargo test 的工作流程大致是:
- 用 test profile 重新编译一遍代码,把所有标了
#[test]的函数挑出来 - 生成一个测试可执行文件(test binary)
- 跑这个 binary,默认多线程并发跑测试
这里第三点要特别注意:Rust 默认就是并发跑测试的,不像 Java surefire 默认是串行的(要并发得自己配置 forkCount/parallel)。这意味着你的测试如果共享了什么全局状态(比如改了同一个文件、连了同一个数据库),可能会互相打架。如果你确实需要串行:
cargo test -- --test-threads=1
-- 后面的参数是传给 test binary 自己的,前面的参数是给 cargo 的,这个分隔有点像 Maven 的 -Dargs=...,刚开始容易绕。
跟 surefire 还有一个差别:Java 的测试类要满足命名约定(*Test、Test* 之类)才会被扫描,Rust 这边完全不看名字,只看你函数上有没有打 #[test] 这个标记。所以你可以把测试函数命名成 test_something、should_xxx,甚至 这个函数能跑通吗(中文标识符 Rust 是支持的)也行,看个人习惯。
二、#[test]:最简单的测试
fn add(a: i32, b: i32) -> i32 {
a + b
}
#[test]
fn test_add_basic() {
assert_eq!(add(1, 2), 3);
}
#[test]
fn test_add_negative() {
assert_eq!(add(-1, -2), -3);
}
跑一下:
$ cargo test
running 2 tests
test test_add_basic ... ok
test test_add_negative ... ok
test result: ok. 2 passed; 0 failed; 0 ignored
#[test] 这个属性的角色就完全相当于 Java 的 @Test。怎么样,看起来是不是非常简单。
下面是一些常用的"修饰"属性,对应 Java 的几个常用注解:
#[test]
#[ignore = "暂时不跑,等 Bob 修完那个 bug 再开"] // 类似 @Disabled
fn test_skip_for_now() { }
#[test]
#[should_panic] // 类似 @Test(expected = ...)
fn test_must_panic() {
panic!("挂了")
}
#[test]
#[should_panic(expected = "ID 不合法")] // 进一步要求 panic 信息里包含某段文本
fn test_panic_message() {
panic!("ID 不合法: -1")
}
#[ignore] 标记的测试默认不跑,需要显式:
cargo test -- --ignored # 只跑被 ignore 掉的
cargo test -- --include-ignored # ignored 和正常的一起跑
到这里 #[test] 的基本面就过完了,下面我们看一个 Rust 更有特色的设计——测试代码放在哪里。
三、单元测试放哪里:跟代码住在一起
Java 的项目结构大家熟得不能再熟了:
src/main/java/com/foo/UserService.java
src/test/java/com/foo/UserServiceTest.java
main 和 test 严格分开,构建的时候 surefire 把 src/test/java 单独编译一遍。这种设计的副作用是:测试访问不了被测类的 private 字段或方法,要么打开可见性(package-private),要么靠反射,要么用 PowerMock 这类东西硬干。
Rust 的玩法非常不一样。Rust 的单元测试和被测代码住在同一个文件里:
// src/calculator.rs
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
fn private_helper(x: i32) -> i32 {
x * 2
}
#[cfg(test)]
mod tests {
use super::*; // 把外层的所有东西(包括 private 的)拿进来
#[test]
fn test_add() {
assert_eq!(add(1, 2), 3);
}
#[test]
fn test_private_helper() {
// 注意,private_helper 在外面是 pub(self) 的,但同一文件内的 mod 完全能访问
assert_eq!(private_helper(3), 6);
}
}
这里有几个关键点:
#[cfg(test)]是条件编译的标记,意思是"只在编译 test profile 的时候才编译这个 mod"。cargo build是不会把这一坨编译进最终产物的,运行时也没有任何测试代码的开销。mod tests { ... }定义了一个内嵌模块,专门放测试函数,名字叫不叫tests都行,习惯上叫tests而已。use super::*;把外层模块的所有项(包括 private 的)一次性导入进来,这样测试就能直接调add、private_helper,不用考虑可见性问题。
这个设计第一眼看可能怪:测试代码和业务代码挤在一起?但其实它解决了 Java 那边一个很实际的问题——测试 private 函数。Rust 的视角是:private 函数是"模块的内部实现细节",而模块的测试天然就是模块的一部分,所以放在一起完全合理。
跟 Java 的对比可以这么记:
- Java 的测试是"另一个 source set",测试和被测代码是两个独立的编译单位
- Rust 的测试是"同一个模块的子模块",编译时通过
#[cfg(test)]决定要不要编进来
一个常见疑问:那为啥还有个 tests/ 目录?
如果你瞄一眼 Cargo new 出来的项目模板,可能没有 tests/ 目录,但很多开源项目都有。这个目录是专门放集成测试的,跟上面这个 #[cfg(test)] mod tests 是两种不同的东西,下一节专门讲。
四、tests/ 目录:集成测试
我们先回忆一下上一篇 Rust 入门里讲过的:一个 Cargo 项目可以是 binary(src/main.rs)或 library(src/lib.rs),library 项目对外暴露的就是 pub 的部分,私有的部分外人看不到。
这一点对集成测试很关键。集成测试要从外部用户的视角来用你的库,所以应该只能调用 pub 的 API。Rust 用 tests/ 目录这个机制来强制这一点:
my_lib/
├── Cargo.toml
├── src/
│ ├── lib.rs
│ └── calculator.rs
└── tests/
├── basic_test.rs
└── advanced_test.rs
tests/ 下每个 .rs 文件,Cargo 都会把它当成一个独立的 crate 来编译,跟你的 lib crate 完全隔离。也就是说:
// tests/basic_test.rs
use my_lib::add; // 必须像别的用户一样 use 进来
#[test]
fn test_add_from_outside() {
assert_eq!(add(1, 2), 3);
}
这里你只能用 my_lib 公开(pub)出来的东西,private 函数想都别想。这跟 Java 的 src/test/java 行为非常像,差别在于:Java 那边是同一个包就能看到 package-private,Rust 这边连 pub(crate) 的东西也碰不到,更严格一些。
tests/ 目录还有几个细节值得说一下:
1. 每个文件是一个独立的 binary
tests/foo.rs 和 tests/bar.rs 编译出来是两个独立的 test binary,互相之间没有 import 关系。所以你不能在 tests/foo.rs 里 use bar::* 之类。
2. 共享代码放在 tests/common/mod.rs
如果几个集成测试要共享一些 helper,比如启动一个测试用的 HTTP server,约定是这样写:
tests/
├── common/
│ └── mod.rs // 共享的 helper,注意必须是 mod.rs 这个名字
├── api_test.rs
└── auth_test.rs
// tests/api_test.rs
mod common; // 把 common 当成自己的子模块用
#[test]
fn test_api() {
let server = common::start_test_server();
// ...
}
为啥不直接写成 tests/common.rs?因为如果叫 common.rs,Cargo 会把它当成一个独立的测试 binary 去跑,里面也得有 #[test] 函数。tests/common/mod.rs 这种目录写法,Cargo 才不会把它当成测试入口。
3. 跟 Java 的 Failsafe 对比
Maven 用 surefire 跑单元测试、failsafe 跑集成测试,本质是把 IT 单独挪到 verify 阶段,确保资源能清理干净。Rust 这边没有这一套,单测和集成测试都是 cargo test 一起跑,速度其实差不多。如果你确实有"集成测试需要单独跑"的需求,可以:
cargo test --lib # 只跑 src/ 下的单元测试
cargo test --test basic_test # 只跑 tests/basic_test.rs
cargo test --tests # 跑所有测试(lib + tests/)
Rust 没有"测试失败但还是要清理资源"这个概念,因为 panic 之后 destructor 还是会跑(这就是 RAII),资源会自动释放。所以也不需要 Failsafe 这种"先收尾再报告失败"的机制。
五、断言:三个就够用
Rust 标准库提供的断言总共就这么几个:
assert!(condition); // 类似 Java 的 assertTrue
assert!(condition, "失败消息: {}", value); // 带格式化消息
assert_eq!(left, right); // 类似 assertEquals
assert_eq!(left, right, "可选消息");
assert_ne!(left, right); // 类似 assertNotEquals
是的,就这三个,剩下的全靠 assert! + 各种条件表达式来组合。比如要断言"是某个变体"或者"在某个范围内",写法就是:
assert!(matches!(result, Some(_))); // 是 Some
assert!(value >= 0 && value < 100); // 在某个范围
assert!(s.contains("error")); // 字符串包含
跟 Java 内置的 Assertions 比起来,Rust 的标准库断言简陋得多。Java 那边有 assertNotNull、assertArrayEquals、assertThrows、assertAll 一大堆。Rust 这边走的是另一条路:用语言本身的能力(比如模式匹配、?、Result)来表达,不去额外造一堆断言函数。
举个例子,Java 那边想断言"调用某段代码会抛某个异常":
assertThrows(IllegalArgumentException.class, () -> userService.create(null));
Rust 这边没有 assertThrows,因为根本不需要——Result 是一个普通的值,直接 match 就行:
let err = user_service.create(None).unwrap_err();
assert!(matches!(err, UserError::InvalidInput(_)));
要断言"恰好不抛异常",就更简单了:直接调用,函数返回 Result 的话用 ? 或者 .unwrap() 就好,挂了 panic 直接让测试失败。
失败消息长啥样
assert_eq! 失败的时候,输出会自动把两边的值用 Debug 格式打出来:
thread 'tests::test_add' panicked at src/lib.rs:8:9:
assertion `left == right` failed
left: 3
right: 4
所以你要断言的类型必须实现 Debug trait(绝大多数标准库类型都自带),自己定义的 struct 一般 derive 一下就行:
#[derive(Debug, PartialEq)]
struct User { name: String }
PartialEq 是 assert_eq! 用来比较是否相等的,Debug 是失败时用来打印的,两个都得有。
自定义错误消息
#[test]
fn test_user_id() {
let user = create_user();
assert!(
user.id > 0,
"用户 id 应该大于 0, 实际值是 {}",
user.id
);
}
这里的格式化语法跟 println!、format! 是一样的,{} 是 Display,{:?} 是 Debug。
关于"流畅断言"——AssertJ 在 Rust 里的对应物
Java 那边我们都很喜欢用 AssertJ,链式调用、可读性好。Rust 这边社区也有几个类似的库,比如 pretty_assertions、assert2、spectral。
我个人感觉 Rust 这边对流畅断言的需求没 Java 那么强,原因是 Rust 的模式匹配本身就很表达力,写起来不那么"哑"。如果你确实要更好的失败信息,最常见的选择是 pretty_assertions:
[dev-dependencies]
pretty_assertions = "1"
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq; // 覆盖标准库的 assert_eq
#[test]
fn test_diff() {
assert_eq!(complex_struct_a, complex_struct_b);
// 失败时会输出类似 git diff 的彩色对比
}
}
简单说就是失败消息从"两边值都打一遍"升级成"diff 高亮",结构体大的时候差距很明显。这个库我个人推荐默认引入,几乎没成本。
六、Result 返回值的测试:用 ? 写测试
这是 Rust 里我特别喜欢的一个小特性。我们写业务代码常常是这样:
fn load_user(id: u64) -> Result<User, MyError> {
let row = db.query(id)?;
let user = parse_user(row)?;
Ok(user)
}
? 用得很爽。但 Java 那边写测试的时候,碰到 checked exception 经常这样:
@Test
void testLoadUser() throws Exception {
User user = loadUser(1);
assertEquals("alice", user.getName());
}
抛个 Exception 把烦人的 try/catch 全省掉。Rust 这边对应的写法是:测试函数也可以返回 Result:
#[test]
fn test_load_user() -> Result<(), MyError> {
let user = load_user(1)?;
assert_eq!(user.name, "alice");
Ok(())
}
测试函数返回 Err(_) 就被认为失败,这跟"测试函数 panic 就被认为失败"是同一个机制下两种不同的失败方式。
实战中我经常配合 anyhow::Result 用,错误类型不用想:
#[test]
fn test_complex_flow() -> anyhow::Result<()> {
let config = load_config("test.toml")?;
let db = connect(&config.db_url)?;
let user = db.find_user(1)?;
assert_eq!(user.name, "alice");
Ok(())
}
这一招简洁好用,强烈推荐。
七、文档测试:Rust 的独门绝活
下面这一节是 Rust 测试体系里最 Rust、最值得说的部分:文档测试(doc test)。Java 这边没有完全对应的东西,所以我们要从头解释。
我们都知道 Java 的 javadoc 长这样:
/**
* 把两个数加起来。
*
* <pre>
* int result = Calculator.add(1, 2); // 3
* </pre>
*/
public static int add(int a, int b) { ... }
那个 <pre> 里的代码示例,没人会去验证它能不能跑。可能你接口签名改了、参数顺序变了、返回类型不对了,文档里的代码早就过时,但 javadoc 不会告诉你。
Rust 的 doc test 干的就是这件事——文档里写的代码也是测试。看一眼:
/// 把两个数加起来。
///
/// # 例子
///
/// ```
/// use my_lib::add;
///
/// let result = add(1, 2);
/// assert_eq!(result, 3);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
注意文档注释里那个用三个反引号包起来的代码块。cargo test 跑的时候会把这段代码当成一个独立的测试函数编译并执行。如果代码不能编译、断言不通过,测试就失败。
这个机制对库作者来说价值极高:
- 文档示例永远不会过期,签名一改文档就会编译错误
- 用户能看到一个一定能跑的最小示例
- 不需要额外维护一份示例代码
跑文档测试:
cargo test --doc
输出大致是:
running 1 test
test src/lib.rs - add (line 5) ... ok
我们再看几个比较常用的"修饰符":
/// ```ignore
/// // 这段代码不会被运行(但会被语法检查)
/// some_code();
/// ```
/// ```no_run
/// // 这段代码会编译,但不会运行(适合不能在 CI 里跑的代码,比如要联网或者 sleep 很久)
/// std::thread::sleep(std::time::Duration::from_secs(60));
/// ```
/// ```should_panic
/// // 这段代码运行时必须 panic
/// assert_eq!(1, 2);
/// ```
/// ```compile_fail
/// // 这段代码必须编译不过(用来证明某些用法是被编译器拒绝的,常见于讲生命周期、所有权的库)
/// let s = String::from("hi");
/// drop(s);
/// println!("{}", s);
/// ```
doc test 还有一个有意思的细节:默认情况下,每段 ``` 包起来的代码都会被自动包一层 fn main() {}。也就是说你写 let x = 5; 不需要自己加 fn main,编译器会帮你包好。如果你不想要这个自动包装(比如想自己写 main、定义函数),加 #![no_implicit_prelude] 之类的属性就行,但 99% 的情况下用默认的就够了。
那,要不要给所有 pub 函数都写 doc test?我的经验:
- 库代码(要发布到 crates.io):尽量写。这是 Rust 库的"门面",文档质量是别人选你的库的重要因素。
- 业务代码(公司内部应用):不强求。一般写在最关键的对外 API 上,内部细节用普通的
#[cfg(test)]单元测试就够了。
doc test 的存在让 Rust 的文档生态比 Java 的 javadoc 健康一大截,这块是 Rust 生态里我个人非常欣赏的一个设计。
八、异步测试:#[tokio::test]
之前那篇 Rust 入门里我们讲过 #[tokio::main]:把 main 函数包成"创建 runtime + block_on"。测试这边也是同一个套路,把 #[test] 换成 #[tokio::test]:
#[tokio::test]
async fn test_async_fetch() {
let user = fetch_user(42).await;
assert_eq!(user.id, 42);
}
这个属性宏会帮你自动起一个 tokio runtime,跑你的 async 测试函数,结束后清理掉。展开后大致相当于:
#[test]
fn test_async_fetch() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let user = fetch_user(42).await;
assert_eq!(user.id, 42);
});
}
默认起的是 current_thread runtime(也就是单线程的),如果你的测试需要多线程的 runtime(比如要 spawn 真的并行执行),加参数:
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_with_multi_thread() {
// ...
}
依赖那边记得开 macros feature:
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] }
dev-dependencies 跟 Java Maven 的 <scope>test</scope> 完全是一回事:只在测试时编译、不进最终产物。
注意 #[tokio::test] 起的 runtime 是每个测试函数一个,不会共享。所以你不能像 Spring Test 那样靠"整个测试套件共享一个 ApplicationContext"来加速,反过来好处是测试之间的隔离非常彻底,没有缓存失效之类的麻烦事。
如果你用别的 runtime(async-std、smol 之类),它们一般也会提供自己的 #[xxx::test],写法基本一致。
九、Mock:mockall 库
终于到 Mockito 的对应物了。这块我建议你先把上一篇 Rust 入门里的 trait 那一节翻出来再看一眼,因为 Rust 的 mock 思路跟 Java 完全不一样,它强依赖你"把依赖写成 trait"。
为什么 Rust 的 mock 这么依赖 trait
Java 的 Mockito 强大在哪?它能直接 mock 一个具体的类,比如:
UserRepository userRepository = mock(UserRepository.class); // UserRepository 是 class,不是 interface
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
Mockito 内部用字节码工具(CGLib、ByteBuddy)在运行时生成一个继承自 UserRepository 的子类,把所有方法重写一遍。这事在 JVM 里不算太难,但在 Rust 这种静态编译、没有反射的语言里根本干不了,所有类型在编译期就定死了,没办法运行时给你"造一个新的 struct 出来"。
所以 Rust 的 mock 只能走另一条路:让被测代码依赖一个 trait(不是具体的 struct),测试时用一个实现了同一个 trait 的假对象去顶替。
举个例子,原本你可能这样写代码:
struct UserService {
db: PostgresRepo, // 直接依赖具体类型,没法 mock
}
要让它可测,先重构成:
trait UserRepo {
fn find(&self, id: u64) -> Option<User>;
fn save(&self, user: &User);
}
struct UserService<R: UserRepo> {
repo: R, // 依赖 trait,具体类型由调用方传
}
这样生产环境传 PostgresRepo,测试时传一个 mock 对象就行。这一套思路其实跟 Java 的"面向接口编程"完全一致,只是 Java 那边大家可能没那么严格地遵守,因为 Mockito 总能给你兜底。
mockall:自动生成 mock 实现
手动写 mock 实现也行,但每个方法都要自己定义"被调用时返回什么",工作量很大。社区主流的 mock 库是 mockall,它通过宏自动生成 mock 实现。
[dev-dependencies]
mockall = "0.12"
最简单的用法:
use mockall::{automock, predicate::*};
#[automock] // 自动生成 MockUserRepo
trait UserRepo {
fn find(&self, id: u64) -> Option<User>;
fn save(&self, user: &User);
}
struct UserService<R: UserRepo> {
repo: R,
}
impl<R: UserRepo> UserService<R> {
fn create(&self, name: String) -> User {
let user = User { id: 0, name };
self.repo.save(&user);
user
}
}
#[test]
fn test_create_user() {
let mut mock = MockUserRepo::new(); // mockall 自动生成的
// expect_save 也是自动生成的,对应 trait 里的 save 方法
mock.expect_save()
.with(eq(User { id: 0, name: "alice".into() })) // 期望参数等于这个
.times(1) // 期望被调用 1 次
.returning(|_| ()); // 调用时返回 ()
let service = UserService { repo: mock };
service.create("alice".into());
// 测试结束时 mockall 会自动 verify expect 里的所有约束
}
我们对着 Java Mockito 的 Mock-Stub-Verify 三步法看一下:
MockUserRepo::new()对应mock(UserRepository.class)expect_save().returning(...)对应when(repo.save(...)).thenReturn(...)times(1)对应verify(repo, times(1)).save(...)with(eq(...))对应 Mockito 的参数匹配器
主要差异有两个:
- mockall 把 stub 和 verify 合并成了一步。Mockito 是
when设置返回值、verify验证调用,两步分开;mockall 是expect_xxx一次设好"参数匹配 + 返回值 + 调用次数",测试结束时自动 verify。这种"先声明期望、后自动校验"的风格更接近老牌的 EasyMock 而不是 Mockito。 - trait 必须打
#[automock]才能 mock。如果是别人的库提供的 trait,你打不进去这个属性,那就得用mock!宏手动声明,麻烦一点但也能用。
设计上的影响
我们换个角度看这件事:在 Java 里写 service 时,你脑子里很少会想"这个 dependency 我以后要不要 mock",因为反正 Mockito 总能搞定。但在 Rust 里,"这个东西要不要可 mock"是设计期就要决定的事——决定了它要不要写成 trait。
我个人的经验:
- Service 层、Repository 层,凡是涉及外部依赖(DB、HTTP、消息队列、邮件)的,都用 trait 抽象,方便测试。
- 纯计算、纯数据变换的代码,不要为了"以后能 mock"硬抽 trait,完全没必要。
- 第三方库的类型,如果你想 mock,最常见的姿势是自己写一层 wrapper trait,把它包一下。
这种设计上的"被迫思考"其实挺好的,写多了你会发现可测性和好的架构其实是一回事。
十、参数化测试:rstest
Java 的 @ParameterizedTest 大家应该都比较熟,配合 @CsvSource、@MethodSource 用一组数据跑同一段逻辑。Rust 标准库没有这个东西,但社区有 rstest 这个库,相当好用。
[dev-dependencies]
rstest = "0.18"
最常见的用法:
use rstest::rstest;
#[rstest]
#[case(1, 1, 2)]
#[case(2, 3, 5)]
#[case(10, 20, 30)]
fn test_add(#[case] a: i32, #[case] b: i32, #[case] expected: i32) {
assert_eq!(add(a, b), expected);
}
跑出来:
test test_add::case_1 ... ok
test test_add::case_2 ... ok
test test_add::case_3 ... ok
跟 Java 那边 @CsvSource 用法基本一样。
rstest 还有一个非常好用的"fixture"概念,对应 Java 那边 @BeforeEach 准备的测试数据:
#[fixture]
fn test_user() -> User {
User { id: 1, name: "alice".into() }
}
#[rstest]
fn test_with_fixture(test_user: User) { // 注入 fixture
assert_eq!(test_user.name, "alice");
}
每个测试函数可以按需注入需要的 fixture,不需要的就不写。这比 Java 的"@BeforeEach 给整个测试类做准备"灵活一些,因为有些测试可能根本不需要那些数据。
十一、测试覆盖率:cargo-llvm-cov
JaCoCo 在 Rust 这边对应的是 cargo-llvm-cov(基于 LLVM 的覆盖率工具,比老牌的 tarpaulin 更准、更快,社区推荐)。
安装:
cargo install cargo-llvm-cov
跑覆盖率:
cargo llvm-cov # 文本报告,跟 cargo test 一样跑
cargo llvm-cov --html # 生成 HTML 报告,输出在 target/llvm-cov/html/
cargo llvm-cov --lcov --output-path lcov.info # 输出 lcov 格式,给 Codecov 之类的工具用
跟 JaCoCo 对比:
- 都基于"插桩 + 收集 + 报告"三步走,cargo-llvm-cov 是用 LLVM 自带的 source-based 覆盖率工具(rustc 加
-C instrument-coverage),JaCoCo 是字节码插桩。 - 输出的指标也类似:行覆盖、分支覆盖、函数覆盖。
- 排除文件用
--ignore-filename-regex,对应 JaCoCo 的 excludes 配置。 - 阈值检查用
--fail-under-lines 80,对应 JaCoCo 的<minimum>0.80</minimum>。
# 行覆盖率低于 80% 就让 CI 挂掉
cargo llvm-cov --fail-under-lines 80
有一点跟 Java 不一样:JaCoCo 默认只统计 surefire(UT)的覆盖,IT 的覆盖要单独开 prepare-agent-integration。Rust 这边因为 unit test 和 integration test 都是 cargo test 一把梭,cargo-llvm-cov 默认全部统计,省心。
十二、cargo-nextest:更快的测试 runner
社区里有一个 cargo test 的替代品叫 cargo-nextest,几乎所有重度使用 Rust 的项目都换到这个了。它解决了 cargo test 的几个痛点:
- 每个测试函数跑在独立的进程里,一个测试 panic 不会影响别的(也不会污染全局状态)
- 并行调度更聪明,整体跑得更快
- 输出更清爽,失败的测试一眼就看到
安装:
cargo install cargo-nextest --locked
用法跟 cargo test 几乎一致:
cargo nextest run # 跑所有测试
cargo nextest run -E 'test(some_keyword)' # 用表达式过滤
需要注意一个事情:cargo-nextest 不跑文档测试。doc test 还是要 cargo test --doc 来跑。所以 CI 流程一般是这样的:
cargo nextest run # 跑普通测试,速度更快
cargo test --doc # 跑文档测试
这个工具不是必需的,但用过就回不去了,强烈推荐。
十三、基准测试:criterion
最后简单提一下基准测试,对应 Java 的 JMH。
Rust 标准库内置了一个 #[bench] 属性,但只在 nightly 才能用,所以社区基本都用 criterion 这个库。
[dev-dependencies]
criterion = "0.5"
[[bench]]
name = "my_bench"
harness = false # 关掉 cargo 自带的 bench harness,用 criterion 的
把 bench 文件放在 benches/ 目录(跟 tests/ 平级):
// benches/my_bench.rs
use criterion::{criterion_group, criterion_main, Criterion};
fn fibonacci(n: u64) -> u64 {
if n < 2 { n } else { fibonacci(n - 1) + fibonacci(n - 2) }
}
fn fib_bench(c: &mut Criterion) {
c.bench_function("fib 20", |b| {
b.iter(|| fibonacci(20))
});
}
criterion_group!(benches, fib_bench);
criterion_main!(benches);
跑:
cargo bench
criterion 会自动做暖机(warmup)、跑足够多次取统计结果、跟历史数据对比,输出包含均值、标准差、分位数。整体能力跟 JMH 一个量级,做性能调优时离不开它。
这块我们一般用得不多,知道有这么个东西,需要的时候去翻文档就行。
十四、测试里的几条实践经验
最后说几条我自己写 Rust 测试这几年攒下来的经验:
1. #[cfg(test)] mod tests 写在被测代码同文件里,作为单元测试默认姿势。 这是 Rust 习惯,也方便测私有函数。除非那个文件确实大到难以维护了,再考虑拆。
2. 集成测试放 tests/,按"使用方视角"写。 集成测试只能用 pub API,这是个特性不是 bug——它逼你思考"我对外暴露的 API 是不是好用的"。
3. 业务代码尽量用 Result,而不是 panic。 测试里也是。Result 让你能精确断言"是哪种失败",panic 出错只能看堆栈,调试体验差很多。
4. 涉及外部依赖的,写成 trait + 实现的形式。 这是 Rust mock 的前提,也是好架构的副产物。trait 不要写得太大,几个核心方法就够了,方便 mock。
5. 别为了 mock 而 mock。 纯计算函数直接传值进去测最干净。能用真实对象就别 mock,mock 是为了切断昂贵的依赖(DB、网络、文件系统),不是为了测试好写。
6. 文档示例多写 doc test,长一点的示例放 examples/ 目录。 doc test 能保证文档不腐烂;examples/ 目录下的 .rs 文件可以当成可运行的示例,cargo run --example xxx 就能跑。
7. CI 里同时跑 cargo nextest run 和 cargo test --doc。 前者跑普通测试更快,后者补上文档测试。再加一个 cargo llvm-cov --fail-under-lines 80 卡覆盖率。
总结
整体看下来,Rust 的测试体系骨架跟 Java 是非常一致的:
cargo test对应mvn test#[test]对应@Test#[ignore]对应@Disabled#[should_panic]对应@Test(expected = ...)tests/对应src/test/java(但只能用 pub API,更严格)- mockall 对应 Mockito(但强依赖 trait)
- rstest 对应
@ParameterizedTest - cargo-llvm-cov 对应 JaCoCo
- criterion 对应 JMH
差异主要在几个 Rust 特色的地方:
- 测试是一等公民,不是装个插件挂在生命周期上
- 单元测试和被测代码同文件,靠
#[cfg(test)]条件编译隔离 - Result-returning tests 让错误处理特别顺
- doc test 让文档示例永远不会过期
- mock 强依赖 trait,逼你设计期就考虑可测性
Java 因为有 JVM、字节码增强、反射这些工具,测试框架的"魔法"很多,写起来很爽但有时候会让你忘记设计本身的问题;Rust 这边就比较朴素,测试就是普通代码,能不能测、好不好测,主要看你的设计够不够干净。
总的来说,Rust 的测试体验没有 Java 那么好,但是它工具链统一、cargo test 一把梭,doc test 这种细节也很贴心。
(全文完)
0 条评论