测试
在数十年的软件开发中,人们发现了一个真理:未经测试的软件很难正常工作。 (许多人也会说,经过测试的软件也可能不工作,但我们都是乐观主义者,不是么) 所以,为了确保你的程序可如你预期的那样工作,最好先对其进行测试。
一种简单的方式是在 README
文件中写明你的软件将如何工作。
当你准备好进行一次新的发布时,再过一遍 README
的功能并确定其仍能正常工作。
你还可以写入你的程序如何应对错误的输入,使得这个测试变得更加严格。
另一个绝妙的主意是:在写代码前先写 README
。
自动化测试
现在一切都妥了,但如果手动去完成测试,会耗费大量的时间。 现在,人们更喜欢让计算机来协助完成这些工作,下面我们来谈谈自动化测试。
Rust 有内建的测试框架,让我们试着写出一个测试吧:
#[test]
fn check_answer_validity() {
assert_eq!(answer(), 42);
}
你可以把这段代码放在几乎任何文件,cargo test
将运行它。
这里的关键是 #[test]
属性。构建系统会寻找这类函数,并当作测试去运行它们,
确认他们不会出错(panic)。
现在我们已经了解了如何去编写测试项,那么下一个问题就是要测试什么? 如你所见,为函数写断言非常简单。但 CLI 程序通常不止一个功能函数! 更麻烦的是,它通常要处理用户的输入、读取文件并写入输出。
编写可测试的代码
测试功能有两种互补的方法: 测试构建成完整程序的功能小单元,叫作“单元测试”。 还有就是从外部测试最终的程序,称为“黑盒测试”或“集成测试”。 让我们先从单元测试开始。
为了弄清楚我们要测试什么,让我们先看看我们程序的功能。
grrs
应该打印出,文件中包含符合匹配字符串的行。
所以,让我们给匹配功能写一个单元测试:
我们希望确保我们最重要的逻辑部分能正常工作,
并且以一种不依赖于任何需要设置代码、变量的方式实现(比如,处理 CLI 参数)。
回到我们的 grrs
的第一个实现,
当时我们在 main
函数中添加了如下代码块:
// ...
for line in content.lines() {
if line.contains(&args.pattern) {
println!("{}", line);
}
}
遗憾的是,这样的代码很难测试。
首先,因为它在 main
函数中,所以我们不能简单的直接调用它。
而把这段代码移动到一个函数中,就可以很简单地解决这个问题了:
#![allow(unused)] fn main() { fn find_matches(content: &str, pattern: &str) { for line in content.lines() { if line.contains(pattern) { println!("{}", line); } } } }
现在我们可以在测试中调用这个函数了,让我们来看一下它的输出是什么:
#[test]
fn find_a_match() {
find_matches("lorem ipsum\ndolor sit amet", "lorem");
assert_eq!( // uhhhh
或者,我们还不能……现在,find_matches
会直接将输出打印到 stdout
,比如在终端。
我们不能简单地在测试中捕获它的输出!
这是在实现功能后再编写测试时经常遇到的问题:
我们编写了一个牢固地集成在它所使用的上下文中的函数。
那么,我们怎样才能让这个函数变得可测呢?我们得能获取它的输出。
Rust 的标准库有一些简单的抽象来处理 I/O,在这里我们将使用 std::io::Write
。
这是一个 trait,
它可以抽象我们可以写入的事物,包括字符串和标准输出。
如果你是第一次在 Rust 中看到 “trait” 这个词,也没关系。
特性(trait)是 Rust 最强大的特性之一。
你可以把它看成 Java 中的接口,或 Haskell 的 type classes(看你了解哪个)。
它允许你抽象可由不同类型共享的行为。
使用 trait 的代码可以以非常通用和灵活的方式来实现功能,
但这也意味着它可能难以阅读。
但请不要被它吓到:即使是 Rust 中的老手也不一定能马上理解通用代码的作用。
在这种情况下,考虑具体用途会有所帮助。
比如,现在,我们抽象的行为是“写入”。实现了(“impl”)了它的类型有:
终端的标准输出、文件、内存中的缓存或 TCP 网络连接。
(在std::io::Write
文档 中下翻可查看实现此 trait 的列表)
有了这些知识,让我们修改函数以便接受第三个参数。它应该是一个实现了 Write
的类型。这样,我们就可以在测试中提供一个简单的字符串并对其进行断言。
这是我们的 find_matches
的新版本:
fn find_matches(content: &str, pattern: &str, mut writer: impl std::io::Write) {
for line in content.lines() {
if line.contains(pattern) {
writeln!(writer, "{}", line);
}
}
}
第三个参数是 mut writer
,即一个名为 writer
的可变变量。
它的类型是 impl std::io::Write
,你可理解为“实现了 Write
trait 的任意类型”。
还要注意,我们使用 writeln!(writer, …)
替换了之前的 println!(…)
。
println!
的行为类似于 writeln!
,只不过它总是写入到标准输出里。
现在我们可以测试它的输出了:
#[test]
fn find_a_match() {
let mut result = Vec::new();
find_matches("lorem ipsum\ndolor sit amet", "lorem", &mut result);
assert_eq!(result, b"lorem ipsum\n");
}
现在我们必须修改 main
函数中对 find_matches
的调用,给它加
&mut std::io::stdout()
作为第三个参数。
下面是修改完成之后,使用新版 find_matches
的 mian
函数:
fn main() -> Result<()> {
let args = Cli::from_args();
let content = std::fs::read_to_string(&args.path)
.with_context(|| format!("could not read file `{}`", args.path.display()))?;
find_matches(&content, &args.pattern, &mut std::io::stdout());
Ok(())
}
我们刚刚学习了如何使一段代码变得易于测试。我们做了:
- 找到程序中的一段核心代码,
- 将它封装到一个独立的函数中,
- 让它的使用方法变得更为灵活。
尽管我们的目标仅仅是让它具有可测试性, 但我们最终得到了一段符合 Rust 语言风格且可被复用的代码,这非常棒!
将代码拆分为库和二进制
这里我们还需要做一些事情。到目前为止我们将所有的代码写到了 src/main.rs
里。
这意味着我们的项目只会编译成一个单独的二进制程序。
但我们也可以将我们的代码作为库提供,像这样:
- 将
find_matches
函数放到新的src/lib.rs
文件中。 - 在函数名前(
fn
)添加上pub
标记(现在函数为pub fn find_matches
) 以便这个库的使用者可以访问这个函数。 - 将
src/main.rs
中的find_matches
函数移除。 - 在
fn main
中使用grrs::find_matches
去调用我们在库中的find_matches
函数。
Rust 处理项目的方式非常灵活,尽早地考虑清楚哪些功能要放到库中是个好主意。 例如,你可以考虑先为用于特定程序的逻辑编写一个库,然后像调用任意其它库一样, 在你的 CLI 程序中使用它。又或者,若你的项目会生成多个二进制程序, 你可以将其通用的功能放到库里,提高代码的复用性。
通过运行 CLI 程序来测试它们
到目前为止,我们已经测试了我们程序中的_业务逻辑_,即 find_matches
函数。
这是非常有价值的,并且是迈向实现经过良好测试的代码的第一步。
(通常,这类测试被称为“单元测试”)
但还有许多代码是我们没有测试到的,因为:
我们写的一切都是为了与外界打交道!
想像一下,在你写 main
函数时,不小心留下了一段硬编码的路径字符串,
软件运行时会使用它而非用户提供的参数!
我们也需要编写这类的测试!(这个级别的测试一般被称为“集成测试”或“系统测试”)
从本质上讲,我们仍要编写函数并使用 #[test]
注释它们。
现在的问题是我们要在这些函数中做什么?
例如,我们想像运行一个普通程序一样使用我们项目的主程序。
我们还要将这些测试放入到一个全新的路径下:tests/cli.rs
。
回想一下,grrs
是一个在文件中搜索字符串的小工具。
我们已经测试过了查找匹配项功能。让我们再想想还能测试哪些功能。
这里我想到了一些。
- 如果文件不存在会怎样?
- 当没有搜索到匹配项时返回什么?
- 当我们少写一个(或都没写)参数时,我们的程序会以错误状态退出?
这些都是有效的测试用例。 此外,我们还应该有一个成功的测试用例,即程序正常运行且找到至少一个匹配并打印。
为了让这类的测试更容易,我们将使用到 assert_cmd
箱。
它提供了许多简洁的帮助程序,可以让我们运行我们的主程序并查看它的行为。
此外,我们还将添加 predicates
箱,
来帮助我们为 assert_cmd
的测试项编写断言(且具有很棒的错误消息)。
我们不会将这些依赖放到程序的主依赖中,
而是放到 Cargo.toml
的 dev dependencies
部分。
它们只会在开发时被使用到,而使用时则不会。
[dev-dependencies]
assert_cmd = "2.0"
predicates = "2.0"
设置完成后,让我们来创建我们的 tests/cli.rs
文件:
use assert_cmd::prelude::*; // Add methods on commands
use predicates::prelude::*; // Used for writing assertions
use std::process::Command; // Run programs
#[test]
fn file_doesnt_exist() -> Result<(), Box<dyn std::error::Error>> {
let mut cmd = Command::cargo_bin("grrs")?;
cmd.arg("foobar").arg("test/file/doesnt/exist");
cmd.assert()
.failure()
.stderr(predicate::str::contains("could not read file"));
Ok(())
}
你可以像我们之前做测试时一样,通过运行 cargo test
来运行这个测试。
在第一次运行时可能会稍慢,因为我们要编译出项目的主程序,它在
Command::cargo_bin("grrs")
被调用。
生成测试文件
我们刚编写的测试,只会在检查我们的程序的输入的文件参数不存在时输出的错误信息。 这是很重要的一个测试项,却不是最重要的: 让我们测试下,正确运行程序并打印出文件中匹配项的用例。
首先我们要有一个已知内容的文件,这样我们才能确认正确的输出是什么从而进行测试。 当然我们可以在项目中添加一个文件专门用来进行测试,或者也可以创建临时文件来测试。 在本教程中,我们选择后面一种做法。因为它相对更为灵活且也适用于其它测试用例; 比如,当你要测试去修改一个文件的时候。
为了生成这些临时文件,我们要使用到 assert_fs
箱,
让我们把它添加到 Cargo.toml
的 dev-dependencies
中。
assert_fs = "1.0"
这就是新的测试用例,首先去创建一个临时文件(并获取到它的路径),
然后在里面填充一些内容,再去运行我们的程序来检查我们能否获得正确的输出。
当执行完这个代码后,file
将自动被删除。
use assert_fs::prelude::*;
#[test]
fn find_content_in_file() -> Result<(), Box<dyn std::error::Error>> {
let file = assert_fs::NamedTempFile::new("sample.txt")?;
file.write_str("A test\nActual content\nMore content\nAnother test")?;
let mut cmd = Command::cargo_bin("grrs")?;
cmd.arg("test").arg(file.path());
cmd.assert()
.success()
.stdout(predicate::str::contains("test\nAnother test"));
Ok(())
}
去测试什么?
What to test?
虽然编写集成测试很有意思,但毕竟编写测试是要消耗时间的, 而且当你的程序行为有所变动时可能也需要去更新这些测试。 为了让我们花费的时间变得更有意义,我们应该问下自己,我们要测试什么?
一般来讲,为用户可以观察到的所有类型的行为编写集成测试是一个好主意。 这意味着你不需要去涵盖所有的边界情况:通常会有不同类型的示例, 再依赖于单元测试即可覆盖边界情况。
同样,尝试去测试你不能掌控的东西,并不是一个好主意。
测试 --help
的确切输出布局,会是一个坏主意,
相反,你可能只想检查其中某些元素是否存在。
根据程序的特性,你还可以尝试添加更多的测试技术。
比如,当你尝试去测试到所有的边界情况时,如果你提取了你程序的一部分,
并且发现你已经写了许多作为单元测试的示例情景,你可以看看 proptest
。
如果你的程序会使用任意一个文件并解析它们,请试着写一个 fuzzer
来查找边界条件下的 bugs。