第8章 | 测试与文档、依赖项、发布、工作空间

2024-05-08 15:23:33 浏览数 (1)

8.6 测试与文档

正如 2.3 节所述,Rust 中内置了一个简单的单元测试框架。测试是标有 #[test] 属性的普通函数:

代码语言:javascript复制
#[test]
fn math_works() {
    let x: i32 = 1;
    assert!(x.is_positive());
    assert_eq!(x   1, 2);
}

cargo test 会运行项目中的所有测试:

代码语言:javascript复制
$ cargo test
   Compiling math_test v0.1.0 (file:///.../math_test)
     Running target/release/math_test-e31ed91ae51ebf22

running 1 test
test math_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

(你还会看到一些关于“文档测试”的输出,我们稍后会讲到。)

无论你的 crate 是可执行文件还是库,你都可以通过将参数传给 Cargo 来运行特定测试:cargo test math 会运行名称中包含 math 的所有测试。

测试通常会使用 assert!assert_eq! 这两个来自 Rust 标准库的宏。如果 expr 为真,那么 assert!(expr) 就会成功;否则,它会 panic,导致测试失败。assert_eq!(v1, v2)assert!(v1 == v2) 基本等效,但当断言失败时,其错误消息会展示两个值。

你可以在普通代码中使用这些宏来检查不变条件,但请注意 assert!assert_eq! 会包含在发布构建中。因此,可以改用 debug_assert!debug_assert_eq! 来编写仅在调试构建中检查的断言。

要测试各种出错情况,请将 #[should_panic] 属性添加到你的测试中:

代码语言:javascript复制
/// 正如我们在第7章中所讲的那样,只有当除以零导致panic时,这个测试才能通过
#[test]
#[allow(unconditional_panic, unused_must_use)]
#[should_panic(expected="divide by zero")]
fn test_divide_by_zero_error() {
    1 / 0;  // 应该panic!
}

在这个例子中,还需要添加一个 allow 属性,以让编译器允许我们做一些它本可以静态证明而无法触发 panic 的事情,然后才能执行除法并丢弃答案,因为在正常情况下,它会试图阻止这种愚蠢行为。

还可以从测试中返回 Result<(), E>。只要错误变体实现了 Debug 特型(通常都实现了),你就可以简单地使用 ? 抛弃 Ok 变体以返回 Result

代码语言:javascript复制
use std::num::ParseIntError;

/// 如果"1024"是一个有效的数值(这里正是如此),那么本测试就会通过
#[test]
fn explicit_radix() -> Result<(), ParseIntError> {
  i32::from_str_radix("1024", 10)?;
  Ok(())
}

标有 #[test] 的函数是有条件编译的。普通的 cargo buildcargo build --release 会跳过测试代码。但是当你运行 cargo test 时,Cargo 会分两次来构建你的程序:一次以普通方式,一次带着你的测试和已启用的测试工具。这意味着你的单元测试可以与它们所测试的代码一起使用,按需访问内部实现细节,而且没有运行期成本。但是,这可能会导致一些警告。

代码语言:javascript复制
fn roughly_equal(a: f64, b: f64) -> bool {
    (a - b).abs() < 1e-6
}

#[test]
fn trig_works() {
    use std::f64::consts::PI;
    assert!(roughly_equal(PI.sin(), 0.0));
}

在省略了测试代码的构建中,roughly_equal 似乎从未使用过,于是 Rust 会报错:

代码语言:javascript复制
$ cargo build
   Compiling math_test v0.1.0 (file:///.../math_test)
warning: function is never used: `roughly_equal`
  |
7 | / fn roughly_equal(a: f64, b: f64) -> bool {
8 | |     (a - b).abs() < 1e-6
9 | | }
  | |_^
  |
   = note: #[warn(dead_code)] on by default

因此,当你的测试变得很庞大以至于需要支撑性代码时,应该按照惯例将它们放在 tests 模块中,并使用 #[cfg] 属性声明整个模块仅用于测试:

代码语言:javascript复制
#[cfg(test)]   // 只有在测试时才包含此模块
mod tests {
    fn roughly_equal(a: f64, b: f64) -> bool {
        (a - b).abs() < 1e-6
    }

    #[test]
    fn trig_works() {
        use std::f64::consts::PI;
        assert!(roughly_equal(PI.sin(), 0.0));
    }
}

Rust 的测试工具会使用多个线程同时运行好几个测试,这是 Rust 代码默认线程安全的附带好处之一。要禁用此功能,请运行单个测试 cargo test testname 或运行 cargo test -- --test-threads 1。(第一个 -- 确保 cargo test--test-threads 选项透传给实际执行测试的可执行文件。)这意味着,从严格意义上说,我们在第 2 章中展示的曼德博程序不是该章中第二个而是第三个多线程程序。2.3 节的 cargo test 运行的才是第一个多线程程序。

通常,测试工具只会显示失败测试的输出。如果也想展示成功测试的输出,请运行 cargo test -- --nocapture

笔记 做纯前端开发的职业生涯中,只有一小段时间写过测试代码,刚开始每次提交还必须保证测试覆盖率达到95%以上,后来变成提交必须 路径 测试覆盖率达到100%,那时候的团队很注重技术,对于一个有追求的技术型选手来讲,那是一段美妙的工作时光

8.7 指定依赖项

前面我们看到过告诉 Cargo 从哪里获取项目所依赖的 crate 源代码的一种方法:通过版本号。

代码语言:javascript复制
image = "0.6.1"

还有几种指定依赖项的方法,以及一些关于要使用哪个版本的微妙细节,因此值得在这上面花一些时间来讲一下。

首先,你可能想要使用根本没发布在 crates.io 上的依赖项。一种方法是指定 Git 存储库 URL 和修订号:

代码语言:javascript复制
image = { git = "https://github.com/Piston/image.git", rev = "528f19c" }

这个特定的 crate 是开源的,托管在 GitHub 上,但你也可以轻松地指向托管在公司内网上的私有 Git 存储库。如上述代码所示,你可以指定要使用的特定版本(rev)、标签(tag)或分支名(branch)。(这些都是告诉 Git 要检出哪个源代码版本的方法。)

另一种方法是指定一个包含 crate 源代码的目录:

代码语言:javascript复制
image = { path = "vendor/image" }

如果你的团队有个做版本控制的单一存储库,而且其中包含多个 crate 的源代码,或者可能包含整个依赖图,那么这种方法就很方便。每个 crate 都可以使用相对路径指定其依赖项。

对依赖项进行这种层级的控制是一项非常强大的特性。如果你要使用的任何开源 crate 都不完全符合你的喜好,那么可以简单地对其进行分叉:只需点击 GitHub 上的 Fork 按钮并更改 Cargo.toml 文件中的一行即可。你的下一次 cargo build 将自然使用此 crate 的分叉版本而非官方版本。

8.7.1 版本

当你在 Cargo.toml 文件中写入类似 image = "0.13.0" 这样的内容时,Cargo 会相当宽松地理解它。它会使用自认为与版本 0.13.0 兼容的最新版本的 image

这些兼容性规则改编自语义化版本规范。

  • 以 0.0 开头的版本号还非常原始,所以 Cargo 永远不会假定它能与任何其他版本兼容。
  • 以 0.xx 不为 0)开头的版本号,可认为与 0.x 系列的版本兼容。前面我们指定了 image 版本为 0.6.1,但如果可用,则 Cargo 会使用 0.6.3。(这跟语义化版本规范所说的 0.x 版本号规则不太一样,但事实证明这条规则太有用了,不能遗漏。)
  • 一旦项目达到 1.0,只有出现新的主版本号时才会破坏兼容性。因此,如果你要求版本为 2.0.1,那么 Cargo 可能会使用 2.17.99,但不会使用 3.0。

默认情况下版本号是灵活的,否则使用哪个版本的问题很快就会变成过度的束缚。假设一个库 libA 使用 num = "0.1.31",而另一个库 libB 使用 num = "0.1.29"。如果版本号需要完全匹配,则没有项目能够同时使用这两个库。允许 Cargo 使用任何兼容版本是一个更实用的默认设定。

不过,不同的项目在依赖性和版本控制方面有不同的需求。你可以使用一些运算符来指定确切的版本或版本范围,如表 8-3 所示。

表 8-3:在 Cargo.toml 文件中指定版本

代码语言:javascript复制
仅使用确切的版本 0.10.0

你偶尔会看到的另一种版本规范是使用通配符 *。它会告诉 Cargo 任何版本都可以。除非其他 Cargo.toml 文件包含更具体的约束,否则 Cargo 将使用最新的可用版本。doc.crates.io 上的 Cargo 文档更详细地介绍了这些版本规范。

请注意,兼容性规则意味着不能纯粹出于营销目的而选择版本号。这实际上意味着它们是 crate 的维护者与其用户之间的契约。如果你维护一个版本为 1.7 的 crate,并且决定移除一个函数或进行任何其他不完全向后兼容的更改,则必须将版本号提高到 2.0。如果你将其称为 1.8,就相当于声称这个新版本与 1.7 兼容,而用户可能会发现构建失败了。

8.7.2 Cargo.lock

Cargo.toml 中的版本号是刻意保持灵活的,但我们不希望每次构建 Cargo 时都将其升级到最新的库版本。想象一下,当你正处于紧张的调试过程中时,cargo build 突然升级到了新版本的库。这可能带来难以估量的破坏性。调试过程中引入的任何变数都是坏事。事实上,对库而言,从来就没有什么好的时机进行意料之外的改变。

因此,Cargo 有一种内置机制来防止发生这种情况。当第一次构建项目时,Cargo 会输出一个 Cargo.lock 文件,以记录它使用的每个 crate 的确切版本。以后的构建都将参考此文件并继续使用相同的版本。仅当你要求 Cargo 升级时它才会升级到更新版本,方法是手动增加 Cargo.toml 文件中的版本号或运行 cargo update

代码语言:javascript复制
$ cargo update
    Updating registry `https://github.com/rust-lang/crates.io-index`
    Updating libc v0.2.7 -> v0.2.11
    Updating png v0.4.2 -> v0.4.3

cargo update 只会升级到与你在 Cargo.toml 中指定的内容兼容的最新版本。如果你指定了 image = "0.6.1",并且想要升级到版本 0.10.0,则必须自己在 Cargo.toml 中进行更改。下次构建时,Cargo 会将 image 库更新到这个新版本并将新版本号存储在 Cargo.lock 中。

前面的示例展示 Cargo 更新了托管在 crates.io 上的两个 crate。存储在 Git 中的依赖项会发生非常相似的情况。假设 Cargo.toml 文件包含以下内容:

代码语言:javascript复制
image = { git = "https://github.com/Piston/image.git", branch = "master" }

如果 cargo build 发现我们有一个 Cargo.lock 文件,那么它将不会从 Git 存储库中拉取新的更改。相反,它会读取 Cargo.lock 并使用与上次相同的修订版。但是 cargo update 会重新从 master 中拉取,以便我们的下一个构建使用最新版本。

Cargo.lock 是自动生成的,通常不用手动编辑。不过,如果此项目是可执行文件,那你就应该将 Cargo.lock 提交到版本控制。这样,构建项目的每个人总是会获得相同的版本。Cargo.lock 文件的版本历史中会记录这些依赖项更新。

如果你的项目是一个普通的 Rust 库,请不要费心提交 Cargo.lock。你的库的下游用户拥有包含其整个依赖图版本信息的 Cargo.lock 文件,他们将忽略这个库的 Cargo.lock 文件。在极少数情况下,你的项目是一个共享库(比如输出是 .dll 文件、.dylib 文件或 .so 文件),它没有这样的下游 cargo 用户,这时候就应该提交 Cargo.lock。

Cargo.toml 灵活的版本说明符使你可以轻松地在项目中使用 Rust 库,并最大限度地提高库之间的兼容性。Cargo.lock 中的这些详尽记录可以支持跨机器的一致且可重现的构建。它们会共同帮你避开“依赖地狱”的问题。

笔记 前端工程也同样的操作,一般推荐保留 .lock 文件,直接把 lock 文件放到仓库里

8.8 将 crate 发布到 crates.io

如果你已决定将这个蕨类植物模拟库作为开源软件发布,那么,恭喜!这部分很简单。

首先,确保 Cargo 可以为你打包 crate。

代码语言:javascript复制
$ cargo package
warning: manifest has no description, license, license-file, documentation,
homepage or repository. See http://doc.crates.io/manifest.html#package-metadata
for more info.
   Packaging fern_sim v0.1.0 (file:///.../fern_sim)
   Verifying fern_sim v0.1.0 (file:///.../fern_sim)
   Compiling fern_sim v0.1.0 (file:///.../fern_sim/target/package/fern_sim-0.1.0)

cargo package 命令会创建一个文件(在本例中为 target/package/fern_sim-0.1.0.crate),其中包含所有库的源文件(包括 Cargo.toml)。这是你要上传到 crates.io 以便与全世界分享的文件。(可以使用 cargo package --list 来查看包含哪些文件。)然后 Cargo 会像最终用户一样,从 .crate 文件构建这个库,以仔细检查其工作。

Cargo 会警告 Cargo.toml 的 [package] 部分缺少一些对下游用户很重要的信息,比如你分发代码所依据的许可证。警告中给出的 URL 是一个很好的资源,因此我们不会在这里详细解释所有字段。简而言之,你可以通过向 Cargo.toml 添加几行代码来修复此警告。

代码语言:javascript复制
[package]
name = "fern_sim"
version = "0.1.0"
edition = "2021"
authors = ["You <you@example.com>"]
license = "MIT"
homepage = "https://fernsim.example.com/"
repository = "https://gitlair.com/sporeador/fern_sim"
documentation = "http://fernsim.example.com/docs"
description = """
Fern simulation, from the cellular level up.
"""

一旦在 crates.io 上发布了这个 crate,任何下载你的 crate 的人都能看到此 Cargo.toml 文件。因此,如果 authors 字段包含你希望保密的电子邮件地址,那么现在是更改它的时候了。

这个阶段有时会出现的另一个问题是你的 Cargo.toml 文件可能通过 path 指定其他 crate 的位置,如 8.7 节所示:

代码语言:javascript复制
image = { path = "vendor/image" }

对你和你的团队来说,这可能没什么问题。但当其他人下载 fern_sim 库时,他们的计算机上可能不会有与你一样的文件和目录。因此,Cargo 会忽略自动下载的库中的 path 键,而这可能会导致构建错误。解决方案一目了然:如果你的库要发布在 crates.io 上,那么它的依赖项也应该在 crates.io 上。应该指定版本号而不是 path

代码语言:javascript复制
image = "0.13.0"

如果你愿意,可以同时指定一个 path(供你自己在本地构建时优先使用)和一个 version(供所有其他用户使用):

代码语言:javascript复制
image = { path = "vendor/image", version = "0.13.0" }

当然,在这种情况下,你有责任确保两者保持同步。

最后,在发布 crate 之前,你需要登录 crates.io 并获取 API 密钥。这一步很简单:一旦你在 crates.io 上有了账户,其“账户设置”页面就会展示一条 cargo login 命令,就像这样:

代码语言:javascript复制
$ cargo login 5j0dV54BjlXBpUUbfIj7G9DvNl1vsWW1

Cargo 会把密钥保存在配置文件中,API 密钥应该像密码一样保密。因此,你应该只在自己控制的计算机上运行此命令。

这些都完成后,最后一步是运行 cargo publish

代码语言:javascript复制
$ cargo publish
    Updating registry `https://github.com/rust-lang/crates.io-index`
   Uploading fern_sim v0.1.0 (file:///.../fern_sim)

做完这一步,你的库就成为 crates.io 中成千上万个库中的一员了。

笔记 整个过程发布很简单,注意要管理好版本号

8.9 工作空间

随着项目不断“成长”,你最终会写出很多 crate。它们并存于同一个源代码存储库中:

代码语言:javascript复制
fernsoft/
├── .git/...
├── fern_sim/
│   ├── Cargo.toml
│   ├── Cargo.lock
│   ├── src/...
│   └── target/...
├── fern_img/
│   ├── Cargo.toml
│   ├── Cargo.lock
│   ├── src/...
│   └── target/...
└── fern_video/
    ├── Cargo.toml
    ├── Cargo.lock
    ├── src/...
    └── target/...

Cargo 的工作方式是,每个 crate 都有自己的构建目录 target,其中包含该 crate 的所有依赖项的单独构建。这些构建目录是完全独立的。即使两个 crate 具有共同的依赖项,它们也不能共享任何已编译的代码。这好像有点儿浪费。

你可以使用 Cargo 工作空间来节省编译时间和磁盘空间。Cargo 工作空间是一组 crate,它们共享着公共构建目录和 Cargo.lock 文件。

你需要做的就是在存储库的根目录中创建一个 Cargo.toml 文件,并将下面这两行代码放入其中:

代码语言:javascript复制
[workspace]
members = ["fern_sim", "fern_img", "fern_video"]

这里的 fern_sim 等是那些包含你的 crate 的子目录名。这些子目录中所有残存的 Cargo.lock 文件和 target 目录都需要删除。

完成此操作后,任何 crate 中的 cargo build 都会自动在根目录(在本例中为 fernsoft/target)下创建和使用共享构建目录。命令 cargo build --workspace 会构建当前工作空间中的所有 crate。cargo testcargo doc 也能接受 --workspace 选项。

8.10 更多好资源

如果你仍然意犹未尽,Rust 社区还准备了一些你可能感兴趣的资源。

当你在 crates.io 上发布一个开源 crate 时,你的文档会自动渲染并托管在 docs.rs 上,这要归功于 Onur Aslan。

如果你的项目在 GitHub 上,那么 Travis CI 可以在每次推送时构建和测试你的代码。设置起来非常容易,有关详细信息,请参阅 travis-ci.org。如果你已经熟悉 Travis,则可以从下面这个 .travis.yml 文件开始。

代码语言:javascript复制
language: rust
rust:
  - stable

你可以从 crate 的顶层文档型注释生成 README.md 文件。此特性是由 Livio Ribeiro 作为第三方 Cargo 插件提供的。运行 cargo install cargo-readme 来安装此插件,然后运行 cargo readme --help 来学习如何使用它。

我们将继续前行。

虽然 Rust 是一门新语言,但它旨在支持大型、雄心勃勃的项目。它有很棒的工具和活跃的社区。系统程序员也能享受美好。

笔记 合理的目录结构和简洁的说明文档,有助于其他人更好的理解当前 crate

欢迎大家讨论交流 Rust,如果喜欢本文章或感觉文章有用,动动你那发财的小手点个赞再走呗 ^_^

0 人点赞