Rust项目结构与测试

 Rust  包  模块  测试 󰈭 4000字

由于rqdmap/rust-in-competitive-programming项目冉冉升起, 希望对代码更加有条理地进行维护和组织, 因而学习一下cargo中有关项目, 包, 模块, 以及测试的一些内容, 做一个简单够用的知识学习与整理.

实际上是项目代码写了不少了, 整体结构也划分好了, 再来补了完善了这篇博客; 因为大概喜欢写测试的技术人员不多吧:/

Rust的项目与包

Rust中Package(项目)和Crate(包)的区别?

Rust的模块

src/lib.rs包根下有这样一些模块:

 1// 餐厅前厅,用于吃饭
 2mod front_of_house {
 3    mod hosting {
 4        fn add_to_waitlist() {}
 5        fn seat_at_table() {}
 6    }
 7    mod serving {
 8        fn take_order() {}
 9        fn serve_order() {}
10        fn take_payment() {}
11    }
12}

那么整个模块树就长这样:

1crate
2 └── front_of_house
3     ├── hosting
4     │   ├── add_to_waitlist
5     │   └── seat_at_table
6     └── serving
7         ├── take_order
8         ├── serve_order
9         └── take_payment

之所以称src/lib.rssrc/main.rs为包根就是因为其在模块树上在根节点的位置.

模块引用方式

引用模块的两种方式:

  • 绝对路径,从包根开始,路径名以包名或者 crate 作为开头

  • 相对路径,从当前模块开始,以 self,super 或当前模块的标识符作为开头

模块可见性

还是下面的代码为例:

 1// src/lib.rs
 2mod front_of_house {
 3    mod hosting {
 4        fn add_to_waitlist() {}
 5        fn seat_at_table() {}
 6    }
 7    mod serving {
 8        fn take_order() {}
 9        fn serve_order() {}
10        fn take_payment() {}
11    }
12}
13
14pub fn eat_at_restaurant() {
15    // 绝对路径
16    crate::front_of_house::hosting::add_to_waitlist();
17    // 相对路径
18    front_of_house::hosting::add_to_waitlist();
19}

遗憾的是, 当前这份代码并不能成功运行, 其原因在于hosting模块是私有的.

这是由于默认情况下, Rust出于对安全的考虑, 所有的类型都是私有的(包括函数, 方法, 结构体, 常量等); 因而eat_at_restaurant函数无法得知front_of_house拥有什么模块.

此外, 由于eat_at_restaurant函数与front_of_house位于同一个包作用域下, 因而模块front_of_house对该函数可见, 不会报上述的private错误.

那么在mod hosting前方加入pub是否就可以成功执行函数呢? 还是不行, 模块的可见性不等于模块内部的可见性, 函数还是不知道hosting内部有什么方法, 必须进一步将add_to_waitlist前加上pub才可以成功调用!

模块内部也默认为private的考量?

在实际项目中,一个模块需要对外暴露的数据和 API 往往就寥寥数个,如果将模块标记为可见代表着内部项也全部对外可见,那你是不是还得把那些不可见的,一个一个标记为 private?反而是更麻烦的多。

结构体与枚举的可见性

将结构体设置为 pub,但它的所有字段依然是私有的; 将枚举设置为 pub,它的所有字段也将对外可见!

这主要是因为枚举和结构体的使用场景不一样, 如果一个枚举的成员对外不可见, 那么该枚举类型将一无是处! 因此枚举成员的可见性自动跟枚举可见性保持一致, 这样可以简化用户的使用.

模块与文件的分离

如果我们希望使得项目结构更加优雅, 会将模块放入单独的文件/文件夹便于维护. 在上述小餐馆的例子中, 我们将front_of_house分离为一个单独的文件夹:

1.
2├── front_of_house
3│   └── hosting.rs
4└── main.rs

不过这样并不能成功, rustc会提示你:

 1cargo build
 2   Compiling main v0.1.0 (/home/rqdmap/tmp/main)
 3error[E0583]: file not found for module `front_of_house`
 4 --> src/main.rs:1:1
 5  |
 61 | mod front_of_house;
 7  | ^^^^^^^^^^^^^^^^^^^
 8  |
 9  = help: to create the module `front_of_house`, create file "src/front_of_house.rs" or "src/front_of_house/mod.rs"
10
11error[E0433]: failed to resolve: could not find `hosting` in `front_of_house`
12 --> src/main.rs:4:21
13  |
144 |     front_of_house::hosting::add_to_waitlist();
15  |                     ^^^^^^^ could not find `hosting` in `front_of_house`
16
17Some errors have detailed explanations: E0433, E0583.
18For more information about an error, try `rustc --explain E0433`.
19error: could not compile `main` (bin "main") due to 2 previous errors

此时文件夹整体表现的像是一个模块了, rustc-1.30之前只支持在文件夹下使用mod.rs来指定该模块暴露什么出去, 更新的rustc则支持在文件夹同级目录下创建一个与文件夹同名的文件来导出模块, 避免项目中出现大量同名的mod.rs(不太会python, 但可能python就会出现这样的狗屎写法)

我们使用较新的写法, 最终的效果为:

 1$ tree
 2.
 3├── front_of_house
 4│   └── hosting.rs
 5├── front_of_house.rs
 6└── main.rs
 7
 82 directories, 3 files
 9
10
11$ find . | grep .rs$ | xargs cat
12// hosting.rs
13pub fn add_to_waitlist() {}
14
15// main.rs
16mod front_of_house;
17fn main() {
18    front_of_house::hosting::add_to_waitlist();
19}
20
21// front_of_house.rs
22pub mod hosting;

目前是硬编码文件名进入代码源文件的:P 是否有什么优雅的shell写法可以直接将文件名附在cat之前?..

牛刀小试

看看这个项目的结构如何? rqdmap/rust-in-competitive-programming

代码树是这样的:

 1$ tree
 2.
 3├── build.rs
 4├── bundle
 5│   └── main-bundle.rs
 6├── Cargo.lock
 7├── Cargo.toml
 8├── in
 9├── LICENSE
10├── README.md
11└── src
12    ├── basic
13    │   ├── io.rs
14    │   └── mod.rs
15    ├── bin
16    │   └── main.rs
17    ├── ds
18    │   ├── mod.rs
19    │   └── segment_tree.rs
20    ├── lib.rs
21    └── math
22        ├── mod.rs
23        └── number_theory.rs

就定义上来说这应该是一个Lib类型的Cargo项目, 尽管存在src/bin/目录, 但是个人认为这仅仅是一个单挂在项目外的子目录, 仅仅用来编译一些可执行文件, 一个依据是:

  • 顶级包crate::下什么都没有(补全为rust-analyzer提供的lsp支持), 而在rust_in_competitive_programming(在Cargo.toml中定义的项目名称)则可以看到所以我在lib.rs中use的所有函数等:

为了实现一键将所有的模块函数都包含进来, 我选择在lib.rs中包含所有的函数, 即:

1// src/lib.rs
2pub mod basic;
3pub mod math;
4
5pub use crate::math::number_theory::*;
6pub use crate::basic::io::*;

这样, 通过pub use将内部模块的函数提到根目录上, 再使用该lib库时即可通过*通配符直接使用所有的函数, 感觉比较方便.

Cargo的自动化测试

当使用cargo new xxx --lib时, Cargo其实就会为我们自动生成一个测试模块, 下面是一个典型的测试模块:

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

[cfg(test)]是条件编译, 只有使用cargo test子命令时才编译对应的代码; 而#[test]是函数属性, 用于指明哪些是测试函数.

多个测试函数以什么方式运行?

Rust 在默认情况下会为每一个测试函数启动单独的线程去处理,当主线程 main 发现有一个测试线程死掉时,main 会将相应的测试标记为失败。

自定义失败信息

默认的assert!输出的信息比较单调, 事实上除了第一个参数是布尔值以外, 后面还可以跟格式化字符串:

 1fn greeting_contains_name() {
 2    let result = greeting("Sunface");
 3    let target = "孙飞";
 4    assert!(
 5        result.contains(target),
 6        "你的问候中并没有包含目标姓名 {} ,你的问候是 `{}`",
 7        target,
 8        result
 9    );
10}

测试panic

如果希望测试一个应该panic的函数, 则需要为对应的测试函数添加should_panic注解:

 1#[cfg(test)]
 2mod tests {
 3    use super::*;
 4
 5    #[test]
 6    #[should_panic]
 7    fn greater_than_100() {
 8        Guess::new(200);
 9    }
10}

如果程序可能有多个panic信息, 如何精准切割呢? 使用expected参数:

 1// --snip--
 2impl Guess {
 3    pub fn new(value: i32) -> Guess {
 4        if value < 1 {
 5            panic!(
 6                "Guess value must be greater than or equal to 1, got {}.",
 7                value
 8            );
 9        } else if value > 100 {
10            panic!(
11                "Guess value must be less than or equal to 100, got {}.",
12                value
13            );
14        }
15
16        Guess { value }
17    }
18}
19
20#[cfg(test)]
21mod tests {
22    use super::*;
23
24    #[test]
25    #[should_panic(expected = "Guess value must be less than or equal to 100")]
26    fn greater_than_100() {
27        Guess::new(200);
28    }
29}
  • expected后面跟的字符串不一定需要与panic信息完全一致, 只需要是其前缀即可.

测试用例的并行/串行执行

在一些情况下, 多个测试可能会导致一些临界区的出现, 比如修改同一个文件时. 此时的解决方案是传递参数给编译出来的可执行文件:

1$ cargo test -- --test-threads=1
  • --符号前的是提供给cargo test的, 而在之后的则是提供给编译出来的二进制文件的.

  • 线程为1那么自然测试就是串行的.

展示所有的输出

默认情况下, 如果你在测试函数中写了一些println, cargo test仅仅会输出那些未通过测试的函数的输出, 如果想要看到所有的, 那么可以添加一些参数:

1$ cargo test -- --show-output

仅进行部分测试

对于一些中大型项目来说, 每次都对所有的测试点做测试是不现实的, 特别是涉及到我修改的代码仅仅是其中的一小部分时. 因而能够仅仅做一部分测试是非常有必要的.

尽管在编写测试及控制执行 - Rust语言圣经(Rust Course)中说了两种方式(单个测试, 通过名称来过滤), 其实本质上这两种都是统一的.

执行cargo test xxx时, cargo会对所有名称中包含xxx的函数进行测试. 而一个函数的名称由模块名+函数名本身组成, 这意味着不仅可以通过函数名中的关键字做筛选, 还可以直接选定特定的模块做测试.

此外, 可以为测试函数再加上#[ignore]标记, 这样默认情况下cargo就将对其进行忽略, 如果需要重新运行这些忽略的函数则需要添加命令行参数: cargo test -- --ignored.

单元/集成测试

单元测试与集成测试使用的技术并没有什么新东西, 主要是对测试代码的结构组织有了一些新的要求.

单元测试

对于单元测试而言, 其目标是测试某一个代码单元(一般都是函数), 验证该单元是否能按照预期进行工作. 通常的惯例是将测试代码的模块跟待测试的正常代码放入同一个文件中.

rust支持对私有函数做测试:

 1pub fn add_two(a: i32) -> i32 {
 2    internal_adder(a, 2)
 3}
 4
 5fn internal_adder(a: i32, b: i32) -> i32 {
 6    a + b
 7}
 8
 9#[cfg(test)]
10mod tests {
11    use super::*;
12
13    #[test]
14    fn internal() {
15        assert_eq!(4, internal_adder(2, 2));
16    }
17}
  • 两个函数均没有pub前缀, 因而均为私有函数

  • 在同文件的tests模块下, 使用use引入父亲模块的所有内容, 这样就可以对父模块的所有函数做测试了.

集成测试

集成测试的代码通常放在一个专门的目录下, cargo项目自动指定的名称为tests, 其会在该目录找集成测试文件, Cargo 会对该目录下的每个文件都进行自动编译.

tests目录下共享模块?

在集成测试时, 有一些可以共享的功能(比如init, setup等), 如果直接创建一个tests/common.rs文件, 则该文件会被当作一个集成测试文件进行测试, 这并不是我们想要的结果; 正确的做法是创建一个tests/common/mod.rs, 这是因为tests下的子目录就不会被当作独立的包进行测试了.

相比于单元测试, 集成测试是以模块的方式调用pub接口函数做测试的; 一些局部上反应不出来的问题在全局上可能就会暴露出来.

此外, 由于tests目录名就已经指定了该目录的性质, 因而其下的所有文件均无需再使用#[cft(test)]来说明条件编译了.

对于二进制包而言, 无法进行集成测试, 这是由于二进制包无法在其他包中被use引用, 只有lib类型的包才能被引用. 这也是为什么许多rust项目下同时有main.rs和lib.rs, 前者只有主体脉络, 而具体的实现全部放在了lib包中.

嗨! 这里是 rqdmap 的个人博客, 我正关注 GNU/Linux 桌面系统, Linux 内核, 后端开发, Python, Rust 以及一切有趣的计算机技术! 希望我的内容能对你有所帮助~
如果你遇到了任何问题, 包括但不限于: 博客内容说明不清楚或错误; 样式版面混乱等问题, 请通过邮箱 rqdmap@gmail.com 联系我!
修改记录:
  • 2023-09-06 16:33:21rust项目结构与测试
Rust项目结构与测试