首页
/ Rustlings 测试练习精讲:用 assert!、assert_eq! 与 [should_panic] 写出真正能通过的单元测试

Rustlings 测试练习精讲:用 assert!、assert_eq! 与 [should_panic] 写出真正能通过的单元测试

2026-09-04 22:57:53作者:胡唯隽

Rustlings 的 17_tests 练习组是整套练习中少有的“提前插队”内容:exercises/17_tests/README.md 明确说明,这一章刻意跳出 Rust 官方书籍的章节顺序,先讲测试,因为“后面的许多练习都会要求你让测试通过”(原文:Going out of order from the book to cover tests -- many of the following exercises will ask you to make tests pass!)。本指南以这三道测试练习为主体,完整拆解 assert!assert_eq!#[should_panic] 三种核心断言机制的用法、取值要求和失败表现,并结合 Rustlings 自身的运行器源码说明“练习通过”的判定标准,帮你把单元测试从会抄变成会写。

一、练习布局:三道题、三套断言机制,以及 Rustlings 如何判定通过

17_tests 目录下的文件构成如下:

练习文件 考察目标 对应参考解
exercises/17_tests/tests1.rs assert! 条件断言 solutions/17_tests/tests1.rs
exercises/17_tests/tests2.rs assert_eq! 值相等断言 solutions/17_tests/tests2.rs
exercises/17_tests/tests3.rs #[should_panic] 预期 panic 测试 solutions/17_tests/tests3.rs

三道题共享同一个结构:一个普通的 main() 函数(注释写着“You can optionally experiment here”,即可选实验区),外加一个由 #[cfg(test)] 属性修饰的 mod tests 模块。#[cfg(test)] 的含义是:该模块只在 cargo test 编译时才存在,平时运行 cargo run 不会包含它——这正是 Rust 单元测试的标准写法,测试代码与生产代码同文件、同模块树,但物理上被条件编译隔离。

Rustlings 的练习校验也建立在这一机制上。从源码结构看,运行器对每道练习执行的正是 cargo testsrc/exercise.rs 中会丢弃一次编译输出、再调用 cargo test … 并检查其退出状态;src/info_file.rs 的注释同样写明了对练习运行 cargo test。因此这三道练习的“通过标准”不是 cargo run 成功,而是:测试模块里的每一个 #[test] 函数都必须编译成功且断言全部通过。这也是为什么三道题都在 mod tests 里留了空参数或 todo!()——留空意味着编译失败或测试失败,练习自然无法过关。

练习注册关系可以在 dev/Cargo.toml 中查证:tests1tests2tests3 与其 *_sol 解答版都被声明为该 Cargo 包的 bin target,分别指向 exercises/17_tests/*.rssolutions/17_tests/*.rs

二、tests1:用 assert! 做条件断言,并用 ! 取反

exercises/17_tests/tests1.rs 的被测对象是一个最简单的函数:

fn is_even(n: i64) -> bool {
    n % 2 == 0
}

练习给出的骨架是:

#[cfg(test)]
mod tests {
    // TODO: Import `is_even`. You can use a wildcard to import everything in
    // the outer module.

    #[test]
    fn you_can_assert() {
        // TODO: Test the function `is_even` with some values.
        assert!();
        assert!();
    }
}

它考察两个知识点:

  1. 导入被测函数mod tests 是外部模块的兄弟模块,要用通配符导入外部模块的所有项。solutions/17_tests/tests1.rs 的解法是:

    // When writing unit tests, it is common to import everything from the outer
    // module (`super`) using a wildcard.
    use super::*;
    

    supermod tests 的父模块(即文件顶层),use super::*is_even 等符号引入测试模块作用域。这是 Rust 官方书籍推荐、也是本仓库所有测试练习的统一写法。

  2. assert! 的两种用法assert!(expr) 要求 expr 求值为 true,否则测试失败。参考解里用到了两条断言:

    assert!(is_even(0));
    assert!(!is_even(-1));
    //      ^ You can assert `false` using the negation operator `!`.
    
    • 第一条断言“0 是偶数”直接通过;
    • 第二条验证 is_even(-1)false:由于 assert! 只能断言真,需要用逻辑非运算符 ! 对函数结果取反,再交给 assert!。这正是注释强调的细节——断言一个函数“应该返回 false”,方式是断言其否定

    注意参数选取本身也隐含了边界意识:0 是非负偶数,-1 覆盖负数分支,i64 取模对负数结果的行为(-1 % 2 == -1,不等于 0)正好被这条用例间接验证。

三、tests2:用 assert_eq! 精确比较函数返回值

exercises/17_tests/tests2.rs 的被测函数利用位移计算 2 的幂:

// Calculates the power of 2 using a bit shift.
// `1 << n` is equivalent to "2 to the power of n".
fn power_of_2(n: u8) -> u64 {
    1 << n
}

题目要求在 you_can_assert_eq 中补全 4 条 assert_eq!。与 assert! 只判断布尔不同,assert_eq!(left, right) 直接比较两个值是否相等,失败时会同时打印左右两边的实际值,调试定位更快——这是它比 assert!(a == b) 更常用的原因。solutions/17_tests/tests2.rs 给出的四条用例是:

#[test]
fn you_can_assert_eq() {
    assert_eq!(power_of_2(0), 1);
    assert_eq!(power_of_2(1), 2);
    assert_eq!(power_of_2(2), 4);
    assert_eq!(power_of_2(3), 8);
}

从这组用例可以读出两点设计意图:

  • 从左到右、逐位翻倍1, 2, 4, 8 正好是 1 << 01 << 3 的期望值,覆盖了指数的最低几位,任何位运算实现错误(如误写成 n << 1)都会立刻暴露;
  • 类型转换是隐式正确的1 << n1 会按返回类型推断为 u64n: u8 决定位移量。assert_eq! 要求两侧类型一致(都实现 PartialEq 且类型相同),这里 u64 == u64 成立;若手误写成 assert_eq!(power_of_2(0), 1u32),会直接得到编译错误而非测试失败——类型系统在这里替你兜底。

四、tests3:用 #[should_panic] 测试“会 panic 的代码路径”

exercises/17_tests/tests3.rs 是三道题中最完整的案例,被测对象是一个在非法输入下主动 panic! 的构造函数:

struct Rectangle {
    width: i32,
    height: i32,
}

impl Rectangle {
    // Don't change this function.
    fn new(width: i32, height: i32) -> Self {
        if width <= 0 || height <= 0 {
            // Returning a `Result` would be better here. But we want to learn
            // how to test functions that can panic.
            panic!("Rectangle width and height must be positive");
        }

        Rectangle { width, height }
    }
}

注释特意点明:现实中返回 Result 是更好的设计(这一点与后续 13_error_handling 练习的主题呼应),但此处刻意保留 panic 版本,目的就是教你测试会 panic 的函数。练习包含三个测试,逐一说明:

1. 正常路径:字段值断言

#[test]
fn correct_width_and_height() {
    let rect = Rectangle::new(10, 20);
    assert_eq!(todo!(), 10); // Check width
    assert_eq!(todo!(), 20); // Check height
}

todo!() 是一个宏,它让程序直接 panic,占位提示“此处尚未实现”。参考解把它替换为结构体字段访问:

let rect = Rectangle::new(10, 20);
assert_eq!(rect.width, 10);  // Check width
assert_eq!(rect.height, 20); // Check height

注意这里验证的是构造参数确实被原样存进了字段——这是对 Rectangle { width, height } 这种字段缩写构造的回归测试。另外一个仓库级细节:dev/Cargo.toml 的 clippy lint 配置里写着 todo = "forbid"(注释是 “You forgot a todo!()!”),也就是说忘记把 todo!() 替换掉会直接触发 lint 错误,练习从机制上强迫你补全断言。

2. 预期 panic 路径:#[should_panic]

#[test]
fn negative_width() {
    let _rect = Rectangle::new(-10, 10);
}

#[test]
fn negative_height() {
    let _rect = Rectangle::new(10, -10);
}

这两个测试的注释都要求“检查负宽/负高时程序是否 panic”。但原样保留它们,测试一定会失败Rectangle::new(-10, 10) 触发 panic!,而 libtest 默认期望测试函数正常返回,panic 即判失败。

修复方式(见 solutions/17_tests/tests3.rs)是在函数上追加属性:

#[test]
#[should_panic] // Added this attribute to check that the test panics.
fn negative_width() {
    let _rect = Rectangle::new(-10, 10);
}

#[test]
#[should_panic] // Added this attribute to check that the test panics.
fn negative_height() {
    let _rect = Rectangle::new(10, -10);
}

#[should_panic] 反转了通过标准:测试函数 panic 才算通过,正常返回反而算失败。这样就把“非法输入必须被拒绝”这一行为契约固化成了可执行断言。两个测试分别覆盖宽度为负、高度为负两个分支,与 new 中的 width <= 0 || height <= 0 条件一一对应。

顺带一提,dev/Cargo.toml 对练习包配置了 [profile.dev][profile.release] 下的 panic = "abort",这属于 Rustlings 对练习运行环境的统一约束(同文件还通过 unsafe_code = "forbid"unstable_features = "forbid" 等 lint 约束练习代码风格);本练习关注的核心仍是 #[should_panic] 语义本身:为“预期崩溃”的代码路径编写可验证的测试。

五、动手验证:如何确认自己真的做对了

完成修改后,有三层证据链可以自我验证:

  1. 单独跑该练习的测试。Rustlings 把每道练习注册为独立 bin target(见 dev/Cargo.toml),而 Rustlings 运行器内部就是对该练习执行 cargo test 并检查退出码(src/exercise.rs)。你也可以在练习包环境中直接以 cargo test tests1 / cargo test tests2 / cargo test tests3 的方式单独筛选运行,观察输出中 test result: ok 与用例数(tests1/2 各 1 个、tests3 共 3 个);
  2. 观察失败信息。把 assert_eq!(power_of_2(2), 4) 故意改成 3cargo test 会打印左右值不匹配的详细信息;把 #[should_panic] 删掉再跑,negative_width 会因 panic 而失败——这两类失败信息正是断言机制存在的意义;
  3. Rustlings 的自检验证了这套判定。仓库的集成测试目录 tests/test_exercises/ 内置了 test_success.rstest_failure.rscompilation_success.rscompilation_failure.rs 四类最小样例(见 tests/test_exercises/exercises/),配合 tests/integration_tests.rs 验证“测试通过/失败、编译通过/失败”四种状态都能被运行器正确识别——也就是说,你在这三道练习里看到的“通过”信号,与 Rustlings 自身 CI 验证过的判定逻辑是同一套。

六、小结:三道题背后的测试心智模型

  • assert!(cond):断言布尔条件;要断言“为假”时用 ! 取反(tests1 的 !is_even(-1))。
  • assert_eq!(a, b):断言两值相等,失败时打印双方实际值,且要求类型一致(tests2 的四条 2 的幂用例)。
  • #[should_panic]:为“非法输入必须 panic”的行为契约编写测试,反转通过标准(tests3 的负宽/负高用例)。
  • 配套工程事实:测试代码用 #[cfg(test)] 条件编译隔离、用 use super::* 引入被测项;Rustlings 以 cargo test 的退出码判定练习成败,并以 clippy todo = "forbid" 杜绝占位符残留。

掌握这三点之后,后续练习中“make the tests pass”的要求就不再是谜:先看清 #[test] 函数里的断言在验证什么行为,再修改被测代码或补全断言,使 cargo test 全绿即可。

登录后查看全文
热门项目推荐
相关项目推荐