首页 / Rust 入门教程 / 编写单元测试

Rust 入门教程

编写单元测试

本教程共 78 篇 · 第 52 篇 · 更新于 2026-08-08 · 约 9 分钟阅读

RustRust 入门教程单元测试cargo test断言宏should_panic#[test]

本节目标:学会用 #[test] 标注测试函数、用断言宏验证结果、用 #[should_panic] 测试 panic,并用 Result<T, E> 返回测试结论,能看懂 cargo test 的输出。

写代码只是第一步,确认它真的按你想的那样工作,才是落地。Rust 把测试当成语言的一等公民,不需要额外装框架,标准库自带一套完整的测试能力。一个测试函数通常做三件事:准备好数据,跑一段被测代码,再判断结果对不对。

1-1 测试函数长什么样

cargo new adder --lib 新建一个库类型的包时,Cargo 会自动在 src/lib.rs 里塞一个测试模块。先新建看看:

cargo new adder --lib
cd adder

打开 src/lib.rs,你会看到这段:

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }
}

it_works 就是测试函数。它前面那个 #[test] 叫属性(attribute),它的作用是告诉编译器:“这是一个测试,运行时要把它纳入测试清单”。没有这个标记的函数只是普通函数,测试执行器不会去跑它。

Note

tests 这个模块名不是写死的,你可以叫 tests 以外任何名字。但 #[test] 标记不能少,因为模块里既可以有测试函数,也可以有给测试用的辅助函数,编译器靠 #[test] 来区分谁才是真正的测试。

1-2 运行测试与解读输出

adder 目录里运行:

cargo test

你会看到类似这样的输出:

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

几个关键点:

  • running 1 test 表示下面这一批只跑了 1 个测试。
  • test tests::it_works ... ok 里,tests::it_works 是测试的全名,由”模块名::函数名”拼成。
  • test result: ok 后面的数字依次是:通过、失败、忽略、基准、被过滤掉的数量。
  • 下面还有一段 Doc-tests adder,那是文档测试。现在没有文档测试,所以是 0 个。文档测试我们后面专门讲。

再写一个会失败的测试感受一下:

#[cfg(test)]
mod tests {
    #[test]
    fn exploration() {
        assert_eq!(2 + 2, 4);
    }

    #[test]
    fn another() {
        panic!("让这个测试失败");
    }
}

another 直接调用 panic!,所以运行后会失败,输出里会清楚标出 tests::another 失败,并给出 thread '...' panicked at '让这个测试失败' 的信息。

Tip

Rust 默认给每个测试函数开一个独立的系统线程。某个测试线程崩了(panic),主线程就把它标记为失败,其它测试照常跑。所以一个测试挂掉不会连累其它测试。

1-3 断言宏:assert!、assert_eq!、assert_ne!

验证结果靠三类断言宏,它们都在标准库里,无需 use 直接可用。

assert! 接收一个布尔表达式,为 false 就 panic:

#[cfg(test)]
mod tests {
    #[test]
    fn larger_can_hold_smaller() {
        let larger = (10, 10);
        let smaller = (5, 5);
        assert!(larger.0 > smaller.0);
    }
}

assert_eq! 判断两个值相等,底层用 == 比较,不相等就 panic。它还能顺手打印出左右两边的值,方便排错:

#[test]
fn it_adds_two() {
    assert_eq!(4, 2 + 2);
}

assert_ne! 正好相反,判断两个值不相等。它要求类型实现 PartialEqDebug,因为失败时要把两边的值打印出来。

Warning

assert_eq! 比较的两个值必须类型一致。写 assert_eq!(4, add_two(2)) 时,如果 add_two 返回的是 i64 而你写的是 i32 字面量 4,编译器会直接报错。遇到这种类型不一致,记得在字面量后面标注类型,例如 4i64

1-4 自定义失败信息

默认失败信息只告诉你”断言失败”和位置,往往不够用。给断言宏追加格式化参数,就能带上你关心的上下文:

pub fn greeting(name: &str) -> String {
    format!("Hello {}!", name)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("小明");
        let target = "小红";
        assert!(
            result.contains(target),
            "问候语里没有包含目标姓名 {},实际内容是 `{}`",
            target,
            result
        );
    }
}

这段测试会失败,但报错信息会变成:“问候语里没有包含目标姓名 小红,实际内容是 Hello 小明!”。测试一多,这种信息能帮你省下大量排查时间。断言宏的格式化写法和 format! 一模一样。

1-5 用 #[should_panic] 测试 panic

有些函数设计上就是要 panic 的,比如参数越界时直接崩溃。要测这种”预期会崩”的行为,用 #[should_panic] 标注:

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess 的值必须在 1 到 100 之间,实际得到 {}。", value);
        }
        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

greater_than_100 传入 200,触发 panic,而我们用 #[should_panic] 声明”这个测试本来就该 panic”,于是测试通过。如果代码改坏了,传入 200 不再 panic,测试反而失败,提示”test did not panic as expected”。

还能用 expected 进一步精确匹配 panic 信息:

#[test]
#[should_panic(expected = "必须小于等于多少")]
fn greater_than_100() {
    Guess::new(200);
}

expected 只需要是实际 panic 信息的前缀即可。比如实际信息是”Guess 的值必须 less than or equal to 100”,写成 expected = "Guess 的值必须" 也能通过。这样即使你后来调整了文案,只要前缀没变,测试就不会误伤。

1-6 用 Result<T, E> 返回测试结论

panic! 不是万能的。如果你想在测试里用 ? 做链式错误处理,可以不让测试函数 panic,而是返回一个 Result

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() -> Result<(), String> {
        if 2 + 2 == 4 {
            Ok(())
        } else {
            Err(String::from("二加二不等于四"))
        }
    }
}

返回 Ok(()) 表示通过,Err(...) 表示失败,错误字符串会作为失败信息打印。这种写法下不能再使用 #[should_panic],因为 panic 和 Result 是两套机制,二者只能选其一。

Tip

初学阶段,绝大多数测试用 assert_eq! 就够了。等你开始写偏 IO、解析类的代码,需要把错误向上抛时,再考虑 Result 形式的测试。

1-7 初学者常见误区

第一个坑:把测试写在 src/main.rs 里却指望它能跑。二进制包里也能写 #[cfg(test)] 测试,但库包(src/lib.rs)才是单元测试最自然的位置,因为库的函数本来就是要被外部调用的。建议把核心逻辑放进 libmain 只负责”接参数、调库、打印结果”。

第二个坑:误以为 #[test] 函数能被业务逻辑直接调用。测试函数不参与正常编译产物,它们只在 cargo test 时才被编译进一个独立的测试二进制里。正常代码调用不到它们,这是好事,保证测试不会污染发布版本。

第三个坑:忘了 use super::*;。测试模块 testslib.rs 的子模块,要测父模块里的函数,得用 use super::*; 把父模块的东西引进来。模块体系这块如果还模糊,回头翻一下模块那一章。

1-8 测试到底写多少

很多人一听”要写测试”就头大,觉得纯属额外负担。其实测试不是写得越多越好,而是写得值。一个朴素的经验:核心逻辑、容易出错的边界条件,必须写;一眼就能看对的简单赋值,不必写。测试本身也是代码,也要维护,写太多低价值测试反而会拖慢你改需求的速度。

还有一种叫”测试驱动开发”(TDD)的做法:先写测试描述期望行为,再写实现让它通过。新手不一定要严格照做,但养成”改完代码顺手补一条测试”的习惯,能省掉无数回头 debug 的夜晚。另外,测试失败时的报错信息就是你的第一现场,所以断言里带上上下文(如本章讲的自定义失败信息)非常关键——报错越清楚,修得越快,而不是对着一行 assertion failed 发呆。

Tip

别追求测试覆盖率的数字好看。比起”覆盖了 90% 行”,更重要是”关键的错都被拦得住”。一条能拦住真实 bug 的测试,胜过十条凑数的测试。

把这一章练熟,你就有能力给自己的函数写”保险”了。下一章我们看另一种测试:集成测试。