---
title: Rust 测试体系介绍
date: 2026-05-20
---

本文接续上一篇 [写给 Java 程序员的 Rust 入门](/post/rust-for-java-developers)，专门补一下测试这块。

## 一、Cargo test：测试就是一等公民

Java 那边的测试是靠 Maven 把 surefire 插件挂到 `test` 阶段才跑起来的，本质上是构建生命周期里的一站。Rust 这边完全不一样：**测试是 Cargo 的内置能力**，不需要装任何插件。

最常用的命令也就这么几个：

```shell
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` 的工作流程大致是：

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

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

```shell
cargo test -- --test-threads=1
```

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

跟 surefire 还有一个差别：Java 的测试类要满足命名约定（`*Test`、`Test*` 之类）才会被扫描，Rust 这边完全不看名字，只看你函数上有没有打 `#[test]` 这个标记。所以你可以把测试函数命名成 `test_something`、`should_xxx`，甚至 `这个函数能跑通吗`（中文标识符 Rust 是支持的）也行，看个人习惯。

## 二、#[test]：最简单的测试

```rust
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);
}
```

跑一下：

```shell
$ 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 的几个常用注解：

```rust
#[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]` 标记的测试默认不跑，需要显式：

```shell
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 的单元测试和被测代码住在同一个文件里：

```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 的）一次性导入进来，这样测试就能直接调 `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 完全隔离。也就是说：

```rust
// 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
```

```rust
// 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` 一起跑，速度其实差不多。如果你确实有"集成测试需要单独跑"的需求，可以：

```shell
cargo test --lib                  # 只跑 src/ 下的单元测试
cargo test --test basic_test      # 只跑 tests/basic_test.rs
cargo test --tests                # 跑所有测试（lib + tests/）
```

Rust 没有"测试失败但还是要清理资源"这个概念，因为 panic 之后 destructor 还是会跑（这就是 RAII），资源会自动释放。所以也不需要 Failsafe 这种"先收尾再报告失败"的机制。

## 五、断言：三个就够用

Rust 标准库提供的断言总共就这么几个：

```rust
assert!(condition);                          // 类似 Java 的 assertTrue
assert!(condition, "失败消息: {}", value);    // 带格式化消息

assert_eq!(left, right);                     // 类似 assertEquals
assert_eq!(left, right, "可选消息");

assert_ne!(left, right);                     // 类似 assertNotEquals
```

是的，就这三个，剩下的全靠 `assert!` + 各种条件表达式来组合。比如要断言"是某个变体"或者"在某个范围内"，写法就是：

```rust
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 那边想断言"调用某段代码会抛某个异常"：

```java
assertThrows(IllegalArgumentException.class, () -> userService.create(null));
```

Rust 这边没有 `assertThrows`，因为根本不需要——`Result` 是一个普通的值，直接 match 就行：

```rust
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 一下就行：

```rust
#[derive(Debug, PartialEq)]
struct User { name: String }
```

`PartialEq` 是 `assert_eq!` 用来比较是否相等的，`Debug` 是失败时用来打印的，两个都得有。

### 自定义错误消息

```rust
#[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`](https://crates.io/crates/pretty_assertions)、[`assert2`](https://crates.io/crates/assert2)、[`spectral`](https://crates.io/crates/spectral)。

我个人感觉 Rust 这边对流畅断言的需求没 Java 那么强，原因是 Rust 的模式匹配本身就很表达力，写起来不那么"哑"。如果你确实要更好的失败信息，最常见的选择是 `pretty_assertions`：

```toml
[dev-dependencies]
pretty_assertions = "1"
```

```rust
#[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 里我特别喜欢的一个小特性。我们写业务代码常常是这样：

```rust
fn load_user(id: u64) -> Result<User, MyError> {
    let row = db.query(id)?;
    let user = parse_user(row)?;
    Ok(user)
}
```

`?` 用得很爽。但 Java 那边写测试的时候，碰到 checked exception 经常这样：

```java
@Test
void testLoadUser() throws Exception {
    User user = loadUser(1);
    assertEquals("alice", user.getName());
}
```

抛个 `Exception` 把烦人的 try/catch 全省掉。Rust 这边对应的写法是：测试函数也可以返回 `Result`：

```rust
#[test]
fn test_load_user() -> Result<(), MyError> {
    let user = load_user(1)?;
    assert_eq!(user.name, "alice");
    Ok(())
}
```

测试函数返回 `Err(_)` 就被认为失败，这跟"测试函数 panic 就被认为失败"是同一个机制下两种不同的失败方式。

实战中我经常配合 `anyhow::Result` 用，错误类型不用想：

```rust
#[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 长这样：

```java
/**
 * 把两个数加起来。
 *
 * <pre>
 *     int result = Calculator.add(1, 2);  // 3
 * </pre>
 */
public static int add(int a, int b) { ... }
```

那个 `<pre>` 里的代码示例，没人会去验证它能不能跑。可能你接口签名改了、参数顺序变了、返回类型不对了，文档里的代码早就过时，但 javadoc 不会告诉你。

Rust 的 doc test 干的就是这件事——文档里写的代码也是测试。看一眼：

```rust
/// 把两个数加起来。
///
/// # 例子
///
/// ```
/// 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. 不需要额外维护一份示例代码

跑文档测试：

```shell
cargo test --doc
```

输出大致是：

```
running 1 test
test src/lib.rs - add (line 5) ... ok
```

我们再看几个比较常用的"修饰符"：

```rust
/// ```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]`：

```rust
#[tokio::test]
async fn test_async_fetch() {
    let user = fetch_user(42).await;
    assert_eq!(user.id, 42);
}
```

这个属性宏会帮你自动起一个 tokio runtime，跑你的 async 测试函数，结束后清理掉。展开后大致相当于：

```rust
#[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 真的并行执行），加参数：

```rust
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_with_multi_thread() {
    // ...
}
```

依赖那边记得开 macros feature：

```toml
[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 一个具体的类，比如：

```java
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 的假对象去顶替。

举个例子，原本你可能这样写代码：

```rust
struct UserService {
    db: PostgresRepo,    // 直接依赖具体类型，没法 mock
}
```

要让它可测，先重构成：

```rust
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`](https://crates.io/crates/mockall)，它通过宏自动生成 mock 实现。

```toml
[dev-dependencies]
mockall = "0.12"
```

最简单的用法：

```rust
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`](https://crates.io/crates/rstest) 这个库，相当好用。

```toml
[dev-dependencies]
rstest = "0.18"
```

最常见的用法：

```rust
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` 准备的测试数据：

```rust
#[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` 更准、更快，社区推荐）。

安装：

```shell
cargo install cargo-llvm-cov
```

跑覆盖率：

```shell
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>`。

```shell
# 行覆盖率低于 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`](https://nexte.st/)，几乎所有重度使用 Rust 的项目都换到这个了。它解决了 `cargo test` 的几个痛点：

- 每个测试函数跑在独立的进程里，一个测试 panic 不会影响别的（也不会污染全局状态）
- 并行调度更聪明，整体跑得更快
- 输出更清爽，失败的测试一眼就看到

安装：

```shell
cargo install cargo-nextest --locked
```

用法跟 cargo test 几乎一致：

```shell
cargo nextest run                # 跑所有测试
cargo nextest run -E 'test(some_keyword)'   # 用表达式过滤
```

需要注意一个事情：cargo-nextest 不跑文档测试。doc test 还是要 `cargo test --doc` 来跑。所以 CI 流程一般是这样的：

```shell
cargo nextest run     # 跑普通测试，速度更快
cargo test --doc      # 跑文档测试
```

这个工具不是必需的，但用过就回不去了，强烈推荐。

## 十三、基准测试：criterion

最后简单提一下基准测试，对应 Java 的 JMH。

Rust 标准库内置了一个 `#[bench]` 属性，但只在 nightly 才能用，所以社区基本都用 [`criterion`](https://crates.io/crates/criterion) 这个库。

```toml
[dev-dependencies]
criterion = "0.5"

[[bench]]
name = "my_bench"
harness = false       # 关掉 cargo 自带的 bench harness，用 criterion 的
```

把 bench 文件放在 `benches/` 目录（跟 `tests/` 平级）：

```rust
// 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);
```

跑：

```shell
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 这种细节也很贴心。

（全文完）
