本文接续上一篇 写给 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 testcargo build 走的是不同的 profile(test profile),编译产物也是分开放的,跟 main code 不冲突。

cargo test 的工作流程大致是:

  1. 用 test profile 重新编译一遍代码,把所有标了 #[test] 的函数挑出来
  2. 生成一个测试可执行文件(test binary)
  3. 跑这个 binary,默认多线程并发跑测试

这里第三点要特别注意:Rust 默认就是并发跑测试的,不像 Java surefire 默认是串行的(要并发得自己配置 forkCount/parallel)。这意味着你的测试如果共享了什么全局状态(比如改了同一个文件、连了同一个数据库),可能会互相打架。如果你确实需要串行:

cargo test -- --test-threads=1

-- 后面的参数是传给 test binary 自己的,前面的参数是给 cargo 的,这个分隔有点像 Maven 的 -Dargs=...,刚开始容易绕。

跟 surefire 还有一个差别:Java 的测试类要满足命名约定(*TestTest* 之类)才会被扫描,Rust 这边完全不看名字,只看你函数上有没有打 #[test] 这个标记。所以你可以把测试函数命名成 test_somethingshould_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);
    }
}

这里有几个关键点:

  1. #[cfg(test)] 是条件编译的标记,意思是"只在编译 test profile 的时候才编译这个 mod"。cargo build 是不会把这一坨编译进最终产物的,运行时也没有任何测试代码的开销。
  2. mod tests { ... } 定义了一个内嵌模块,专门放测试函数,名字叫不叫 tests 都行,习惯上叫 tests 而已。
  3. use super::*; 把外层模块的所有项(包括 private 的)一次性导入进来,这样测试就能直接调 addprivate_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.rstests/bar.rs 编译出来是两个独立的 test binary,互相之间没有 import 关系。所以你不能在 tests/foo.rsuse 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 那边有 assertNotNullassertArrayEqualsassertThrowsassertAll 一大堆。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 }

PartialEqassert_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_assertionsassert2spectral

我个人感觉 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 跑的时候会把这段代码当成一个独立的测试函数编译并执行。如果代码不能编译、断言不通过,测试就失败。

这个机制对库作者来说价值极高:

  1. 文档示例永远不会过期,签名一改文档就会编译错误
  2. 用户能看到一个一定能跑的最小示例
  3. 不需要额外维护一份示例代码

跑文档测试:

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 的参数匹配器

主要差异有两个:

  1. mockall 把 stub 和 verify 合并成了一步。Mockito 是 when 设置返回值、verify 验证调用,两步分开;mockall 是 expect_xxx 一次设好"参数匹配 + 返回值 + 调用次数",测试结束时自动 verify。这种"先声明期望、后自动校验"的风格更接近老牌的 EasyMock 而不是 Mockito。
  2. 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 runcargo 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 这种细节也很贴心。

(全文完)