使用测试驱动开发实现 Rust 库功能

RustRustBeginner
立即练习

This tutorial is from open-source community. Access the source code

💡 本教程由 AI 辅助翻译自英文原版。如需查看原文,您可以 切换至英文原版

简介

欢迎来到「通过测试驱动开发来开发库的功能」实验。本实验是 《Rust 程序设计语言》 的一部分。你可以在 LabEx 中练习你的 Rust 技能。

在本实验中,我们将使用测试驱动开发来开发库的功能,以便为程序添加搜索逻辑。


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL rust(("Rust")) -.-> rust/BasicConceptsGroup(["Basic Concepts"]) rust(("Rust")) -.-> rust/ControlStructuresGroup(["Control Structures"]) rust(("Rust")) -.-> rust/FunctionsandClosuresGroup(["Functions and Closures"]) rust(("Rust")) -.-> rust/DataStructuresandEnumsGroup(["Data Structures and Enums"]) rust(("Rust")) -.-> rust/AdvancedTopicsGroup(["Advanced Topics"]) rust/BasicConceptsGroup -.-> rust/variable_declarations("Variable Declarations") rust/BasicConceptsGroup -.-> rust/mutable_variables("Mutable Variables") rust/ControlStructuresGroup -.-> rust/for_loop("for Loop") rust/FunctionsandClosuresGroup -.-> rust/function_syntax("Function Syntax") rust/FunctionsandClosuresGroup -.-> rust/expressions_statements("Expressions and Statements") rust/DataStructuresandEnumsGroup -.-> rust/method_syntax("Method Syntax") rust/AdvancedTopicsGroup -.-> rust/operator_overloading("Traits for Operator Overloading") subgraph Lab Skills rust/variable_declarations -.-> lab-100421{{"使用测试驱动开发实现 Rust 库功能"}} rust/mutable_variables -.-> lab-100421{{"使用测试驱动开发实现 Rust 库功能"}} rust/for_loop -.-> lab-100421{{"使用测试驱动开发实现 Rust 库功能"}} rust/function_syntax -.-> lab-100421{{"使用测试驱动开发实现 Rust 库功能"}} rust/expressions_statements -.-> lab-100421{{"使用测试驱动开发实现 Rust 库功能"}} rust/method_syntax -.-> lab-100421{{"使用测试驱动开发实现 Rust 库功能"}} rust/operator_overloading -.-> lab-100421{{"使用测试驱动开发实现 Rust 库功能"}} end

测试驱动开发

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

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

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

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

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

编写一个会失败的测试

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

文件名:src/lib.rs

#[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 函数返回的值只包含我们期望的那一行。

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

文件名:src/lib.rs

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

清单 12-16:定义足够的 search 函数以使我们的测试能够编译

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

换句话说,我们告诉 Rust,search 函数返回的数据将与通过 contents 参数传递给 search 函数的数据具有相同的生命周期。这很重要!切片所引用的数据需要是有效的,引用才是有效的;如果编译器假设我们是在对 query 而不是 contents 进行字符串切片,它将进行错误的安全检查。

如果我们忘记了生命周期注释并尝试编译这个函数,我们会得到这个错误:

error[E0106]: missing lifetime specifier
  --> src/lib.rs:31:10
   |
29 |     query: &str,
   |            ----
30 |     contents: &str,
   |               ----
31 | ) -> 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>(
29 ~     query: &'a str,
30 ~     contents: &'a str,
31 ~ ) -> Vec<&'a str> {
   |

Rust 不可能知道我们需要两个参数中的哪一个,所以我们需要明确地告诉它。因为 contents 是包含我们所有文本的参数,并且我们想要返回该文本中匹配的部分,所以我们知道 contents 是应该使用生命周期语法与返回值相关联的参数。

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

现在让我们运行测试:

[object Object]

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

编写使测试通过的代码

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

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

让我们逐步完成每个步骤,从遍历行开始。

使用 lines 方法逐行遍历

Rust 有一个很有用的方法来处理字符串的逐行遍历,它的名字很方便记忆,叫做 lines,其工作方式如清单 12-17 所示。请注意,这段代码目前还无法编译。

文件名:src/lib.rs

pub fn search<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    for line in contents.lines() {
        // 对 line 进行某些操作
    }
}

清单 12-17:遍历 contents 中的每一行

lines 方法返回一个迭代器。我们将在第 13 章深入讨论迭代器,但回想一下,你在清单 3-5 中见过这种使用迭代器的方式,我们在那里使用了一个带有迭代器的 for 循环,以便对集合中的每个元素运行一些代码。

在每一行中搜索查询字符串

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

文件名:src/lib.rs

pub fn search<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    for line in contents.lines() {
        if line.contains(query) {
            // 对 line 进行某些操作
        }
    }
}

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

目前,我们正在逐步构建功能。为了使代码能够编译,我们需要按照函数签名中的指示从函数体返回一个值。

存储匹配的行

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

文件名:src/lib.rs

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
}

清单 12-19:存储匹配的行以便我们可以返回它们

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

$ cargo test
--snip--
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

我们的测试通过了,所以我们知道它能正常工作!

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

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

文件名:src/lib.rs

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(())
}

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

现在整个程序应该可以正常工作了!让我们来测试一下,首先使用一个应该从艾米莉·狄金森的诗中精确返回一行的词:“frog”。

$ cargo run -- frog poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [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
    Finished dev [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
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep monomorphization poem.txt`

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

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

总结

恭喜你!你已经完成了“通过测试驱动开发来实现库的功能”这个实验。你可以在 LabEx 中练习更多实验来提升你的技能。