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


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


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


  1. 编写一个失败的测试并运行它,以确保它由于您预期的原因而失败。

  2. 编写或修改刚好足以使新测试通过的代码。

  3. 重构您刚刚添加或更改的代码,并确保测试继续通过。

  4. 从第 1 步开始重复!


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


我们将试驾实际执行的功能的实现 在文件内容中搜索查询字符串,并生成一个 与查询匹配的行。我们将在名为 搜索


编写失败的测试


因为我们不再需要它们,所以让我们从 src/lib.rssrc/main.rs,我们用它来检查程序的行为。然后,在 src/lib.rs 中,我们将添加一个带有 test 函数的 tests 模块,就像我们在第 11 章中所做的那样。test 函数指定了我们希望 search 函数具有的行为:它将需要一个查询和文本进行搜索,并且它将只返回包含该查询的文本中的行。示例 12-15 显示了这个测试,它还不会编译。


文件名: 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));
    }
}

示例 12-15:为我们希望拥有的 search 函数创建一个失败的测试


此测试搜索字符串 “duct”。我们正在搜索的文本是三行,其中只有一行包含 “duct” (请注意,左双引号后的反斜杠告诉 Rust 不要在此字符串文字内容的开头放置换行符)。我们断言 search 函数返回的值仅包含我们期望的行。


我们还无法运行此测试并看着它失败,因为测试甚至没有编译:搜索函数还不存在!根据 TDD 原则,我们将添加足够的代码来编译和运行测试,方法是添加一个始终返回空 vector 的搜索函数定义,如示例 12-16 所示。然后,测试应该编译并失败,因为空 vector 与包含行 “safe, fast, productive” 的 vector 不匹配。


文件名: 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));
    }
}

示例 12-16:定义足够的搜索函数,以便我们的测试能够编译


请注意,我们需要在 search 并将该生命周期与 contents 参数和返回值一起使用。回想一下第 10 章,生命周期参数指定了哪个参数生命周期与返回值的生命周期相关联。在这种情况下,我们指示返回的 vector 应该包含引用参数内容切片(而不是参数查询)的字符串切片。


换句话说,我们告诉 Rust,只要数据传递到 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 是应该使用 lifetime 语法连接到返回值的参数。


其他编程语言不需要您将参数连接到签名中的返回值,但随着时间的推移,这种做法会变得更容易。您可能希望将此示例与“验证引用 with Lifetimes“部分。


现在让我们运行测试:

$ 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`


太好了,测试失败了,正如我们预期的那样。让我们通过测试吧!


编写代码以通过测试


目前,我们的测试失败,因为我们总是返回一个空 vector。要解决此问题并实施搜索,我们的程序需要按照以下步骤作:


  1. 遍历内容的每一行。

  2. 检查该行是否包含我们的查询字符串。

  3. 如果是,请将其添加到我们返回的值列表中。

  4. 如果没有,则什么都不做。

  5. 返回匹配的结果列表。


让我们完成每个步骤,从迭代行开始。


使用 lines Method 遍历 Lines


Rust 有一个有用的方法来处理字符串的逐行迭代,方便地命名为 lines,其工作原理如图 12-17 所示。请注意,这还不会编译。


文件名: 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));
    }
}

示例 12-17:遍历 contents 中的每一行


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


在每一行中搜索查询


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


文件名: 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));
    }
}

示例 12-18:添加功能以查看该行是否包含 query 中的字符串


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


存储匹配行


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


文件名: 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));
    }
}

示例 12-19:存储匹配的行,以便我们可以返回它们


现在 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


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


此时,我们可以考虑重构搜索函数的实现的机会,同时保持测试通过以保持相同的功能。search 函数中的代码还不错,但它没有利用迭代器的一些有用功能。我们将在第 13 章中返回此示例,在那里我们将详细探讨迭代器,并了解如何改进它。


run Function 中使用 search Function


现在 search 函数已经运行并经过测试,我们需要调用 search 从我们的 run 函数。我们需要将 config.query 值和 运行的内容从文件中读取到搜索函数。然后运行 将打印从 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 中返回每一行并打印它。


现在整个程序应该可以工作了!让我们试一试,首先用一个应该恰好返回艾米莉·狄金森 (Emily Dickinson) 诗中的一行的词:frog

$ 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`


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


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