使用测试驱动开发开发库的功能

现在我们已经将逻辑提取到src/lib.rs中,并将参数收集和错误处理保留在src/main.rs中,为代码的核心功能编写测试变得更加容易。我们可以直接调用带有不同参数的函数并检查返回值,而无需从命令行调用我们的二进制文件。

在本节中,我们将使用测试驱动开发(TDD)过程为minigrep程序添加搜索逻辑,具体步骤如下:

  1. 编写一个失败的测试并运行它,以确保它确实因为你预期的原因而失败。
  2. 编写或修改足够的代码以使新测试通过。
  3. 重构你刚刚添加或更改的代码,并确保测试继续通过。
  4. 从步骤1重复!

虽然这只是编写软件的众多方法之一,但 TDD 可以帮助驱动代码设计。在编写使测试通过的代码之前先编写测试,有助于在整个过程中保持高测试覆盖率。

我们将测试实现的功能,该功能将在文件内容中搜索查询字符串,并生成与查询匹配的行列表。我们将在一个名为search的函数中添加此功能。

编写一个失败的测试

因为我们不再需要它们了,让我们从 src/lib.rssrc/main.rs 中移除用于检查程序行为的 println! 语句。然后,在 src/lib.rs 中,我们将添加一个 tests 模块,其中包含一个测试函数,就像我们在 第 11 章 中所做的那样。测试函数指定了我们希望 search 函数具有的行为:它将接受一个查询和要搜索的文本,并仅返回包含查询的文本行。列表 12-15 显示了这个测试,但目前还无法编译。

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-15: Creating a failing test for the search function we wish we had

这个测试查找字符串"duct"。我们要搜索的文本有三行,只有一行包含"duct"(注意,开头双引号后的反斜杠告诉Rust不要在该字符串字面量的开头添加换行符)。我们断言search函数返回的值只包含我们预期的那一行。

我们还不能运行这个测试并看着它失败,因为测试甚至无法编译:search 函数还不存在!根据 TDD 原则,我们将添加足够的代码以使测试能够编译和运行,方法是添加一个总是返回空向量的 search 函数定义,如列表 12-16 所示。然后测试应该能够编译并失败,因为一个空向量与包含行 "safe, fast, productive." 的向量不匹配。

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    vec![]
}

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-16: Defining just enough of the search function so our test will compile

请注意,我们需要在 search 的签名中定义一个显式的生命周期 'a,并使用该生命周期与 contents 参数和返回值。回想 第 10 章 中,生命周期参数指定了哪个参数的生命周期与返回值的生命周期相关联。在这种情况下,我们表示返回的向量应包含引用 contents 参数的字符串切片(而不是 query 参数)。

换句话说,我们告诉 Rust search 函数返回的数据 将与传递给 search 函数的 contents 参数中的数据一样长寿。这很重要!切片引用的数据需要有效,引用才能有效;如果编译器假设我们正在对 query 而不是 contents 进行字符串切片,它将错误地进行安全检查。

如果我们忘记生命周期注解并尝试编译此函数,我们将得到以下错误:

$ cargo build
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
error[E0106]: missing lifetime specifier
  --> src/lib.rs:28:51
   |
28 | pub fn search(query: &str, contents: &str) -> Vec<&str> {
   |                      ----            ----         ^ expected named lifetime parameter
   |
   = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `query` or `contents`
help: consider introducing a named lifetime parameter
   |
28 | pub fn search<'a>(query: &'a str, contents: &'a str) -> Vec<&'a str> {
   |              ++++         ++                 ++              ++

For more information about this error, try `rustc --explain E0106`.
error: could not compile `minigrep` (lib) due to 1 previous error

Rust 无法知道我们具体需要哪个参数,所以我们需要明确地告诉它。因为 contents 是包含我们所有文本的参数,而我们希望返回与之匹配的文本部分,所以我们知道 contents 是应该使用生命周期语法与返回值关联的参数。

其他编程语言不要求你在签名中将参数与返回值连接起来,但随着时间的推移,这种做法会变得更容易。你可能想要将此示例与第 10 章“使用生命周期验证引用”部分中的示例进行比较。

现在让我们运行测试:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.97s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 1 test
test tests::one_result ... FAILED

failures:

---- tests::one_result stdout ----
thread 'tests::one_result' panicked at src/lib.rs:44:9:
assertion `left == right` failed
  left: ["safe, fast, productive."]
 right: []
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::one_result

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

error: test failed, to rerun pass `--lib`

很好,测试失败了,正如我们所预期的。让我们让测试通过!

编写通过测试的代码

目前,我们的测试失败是因为我们总是返回一个空的向量。要修复这个问题并实现search,我们的程序需要遵循以下步骤:

  1. 遍历内容的每一行。
  2. 检查该行是否包含我们的查询字符串。
  3. 如果可以,将其添加到我们要返回的值列表中。
  4. 如果不行,什么也不做。
  5. 返回匹配的结果列表。

让我们逐步进行,从遍历行开始。

Iterating Through Lines with the lines Method

Rust 有一个有助于处理字符串逐行迭代的有用方法,方便地命名为 lines,如清单 12-17 所示。请注意,这还不能编译。

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        // do something with line
    }
}

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-17: Iterating through each line in contents

lines 方法返回一个迭代器。我们将在第 13 章中深入讨论迭代器,但请回想一下,您在示例 3-5中看到了这种使用迭代器的方式,我们在其中使用了一个for循环和迭代器来对集合中的每个项目运行一些代码。

Searching Each Line for the Query

接下来,我们将检查当前行是否包含我们的查询字符串。 幸运的是,字符串有一个名为contains的有用方法,可以为我们完成这项工作!在search函数中添加对contains方法的调用,如清单12-18所示。请注意,这仍然无法编译。

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        if line.contains(query) {
            // do something with line
        }
    }
}

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-18: Adding functionality to see whether the line contains the string in query

目前,我们正在构建功能。为了让代码编译通过,我们需要从函数体中返回一个值,正如我们在函数签名中所指示的那样。

Storing Matching Lines

为了完成这个函数,我们需要一种方法来存储我们想要返回的匹配行。为此,我们可以在 for 循环之前创建一个可变向量,并调用 push 方法将 line 存储在向量中。在 for 循环之后,我们返回该向量,如列表 12-19 所示。

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-19: Storing the lines that match so we can return them

现在 search 函数应该只返回包含 query 的行, 并且我们的测试应该通过。让我们运行测试:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.22s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 1 test
test tests::one_result ... ok

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

     Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

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

   Doc-tests minigrep

running 0 tests

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

我们的测试通过了,所以我们知道它有效!

在这一点上,我们可以考虑在保持测试通过以维护相同功能的同时,重构搜索函数的实现的机会。搜索函数中的代码还不错,但没有充分利用迭代器的一些有用特性。我们将在第13章中详细探讨迭代器,并看看如何改进它。

Using the search Function in the run Function

现在 search 函数已经可以工作并且经过了测试,我们需要从 run 函数中调用 search。我们需要将 config.query 值和 run 从文件中读取的 contents 传递给 search 函数。然后 run 将打印从 search 返回的每一行:

文件名: src/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

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

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

我们仍然使用 for 循环从 search 返回每一行并打印它。

现在整个程序应该可以工作了!让我们先试一下,用一个应该从艾米莉·狄金森的诗中返回恰好一行的词:青蛙

$ cargo run -- frog poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s
     Running `target/debug/minigrep frog poem.txt`
How public, like a frog

酷!现在让我们尝试一个可以匹配多行的单词,比如 body

$ cargo run -- body poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep body poem.txt`
I'm nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!

最后,让我们确保在搜索诗中不存在的单词时,比如monomorphization,不会得到任何行:

$ cargo run -- monomorphization poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep monomorphization poem.txt`

优秀!我们构建了自己的迷你版经典工具,并学到了很多关于如何构建应用程序的知识。我们还学到了一些关于文件输入和输出、生命周期、测试和命令行解析的知识。

为了完善这个项目,我们将简要演示如何使用环境变量以及如何打印到标准错误,这两者在编写命令行程序时都非常有用。