Rust 开发命令行工具(中)(二)

简介: Rust 开发命令行工具(中)(二)

单元测试

有两种互补的方法来测试功能:

  1. 单元测试(unit tests):测试构建完整应用程序的小单元。
  2. 黑盒测试(black box tests)或集成测试(integration tests):测试最终应用程序的“外部”。

让我们先从单元测试开始。

决定去远方,需要一个目的地,我们想要测试哪些东西,我们就需要知道我们的程序功能是啥!总的来说,f789应该打印出与给定模式匹配的行。因此,让我们为这个编写单元测试:我们希望确保我们最重要的逻辑部分有效,并且我们希望以不依赖于我们周围的任何设置代码(例如处理CLI参数等)的方式来执行此操作。

回到我们的f789的第一个实现,我们在main函数中添加了这个代码块:

// ...
for line in content.lines() {
    if line.contains(&args.pattern) {
        println!("{}", line);
    }
}

上面的代码是不容易进行单元测试的。首先,它在main函数中,因此我们不能轻松地调用它。所以,我们需要将它移出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("front\n789", "789");
    assert_eq!( // 省略了部分代码

目前,find_matches通过stdout将内容直接打印到了终端。我们并不能轻松地在测试中捕获这个信息,并且它是不可调试的。

我们需要以某种方式捕获输出。幸运的是:Rust的标准库提供了一些处理I/O的方式,我们可以使用其中一个称为std::io::Writetrait,它可用于我们可以写入的东西,包括字符串,还有stdout

有了Wirte的加持,让我们更改我们的函数以接受第三个参数。它应该是实现了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是可变(mutable)的。它的类型是impl std::io::Write,我们可以将其解读为实现了Write trait的任何类型的占位符。还要注意,我们用writeln!(writer, …)替换了之前使用的println!(…)println!writeln!的工作方式相同,但始终使用标准输出。

现在我们可以测试输出:

#[test]
fn find_a_match() {
    let mut result = Vec::new();
    find_matches("front\n789", "789", &mut result);
    assert_eq!(result, b"789\n");
}

要在我们的应用程序代码中使用它,我们必须更改main中对find_matches的调用,通过将&mut std::io::stdout()作为第三个参数添加。

fn main() -> Result<()> {
    let args = Cli::parse();
    let content = std::fs::read_to_string(&args.path)
        .with_context(|| format!("无法读取文件 `{}`", args.path.display()))?;
    find_matches(&content, &args.pattern, &mut std::io::stdout());
    Ok(())
}

注意:由于stdout需要字节(而不是字符串),我们使用std::io::Write而不是std::fmt::Write。因此,在我们的测试中,我们给出一个空向量(vector)作为writer(其类型将被推断为Vec<u8>),在assert_eq!中,我们使用b"foo"。(b前缀将其转换为字节字符串文字,因此其类型将为&[u8],而不是&str)。

我们来看最终被改造后的代码。

use anyhow::{Context, Result};
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser)]
struct Cli {
    /// 要查找的模式
    pattern: String,
    /// 要读取的文件的路径
    path: PathBuf,
}
fn main() -> Result<()> {
    let args = Cli::parse();
    let content = std::fs::read_to_string(&args.path)
        .with_context(|| format!("无法读取文件`{}`", args.path.display()))?;
    find_matches(&content, &args.pattern, &mut std::io::stdout());
    Ok(())
}
fn find_matches(content: &str, pattern: &str, mut writer: impl std::io::Write) {
    #[allow(unused_must_use)]
    for line in content.lines() {
        if line.contains(pattern) {
            writeln!(writer, "{}", line);
        }
    }
}
#[test]
fn find_a_match() {
    let mut result = Vec::new();
    find_matches("front\n789", "789", &mut result);
    assert_eq!(result, b"789\n");
}

使用cargo test运行上面的代码,运行结果如下:

image.png


将代码拆分为库(library)和二进制(binary)

到目前为止,我们把所有代码都放在了src/main.rs文件中。这意味着我们当前的项目生成一个单独的二进制文件。但是我们也可以将我们的代码作为一个库提供,方法如下:

  1. find_matches函数放入一个新的src/lib.rs文件中。
  2. fn前面加上pub(这样它就是pub fn find_matches),以使其成为我们库的用户可以访问的内容。
  3. src/main.rs中删除find_matches
  4. fn main中,在调用find_matches之前加上f789::,这样它现在是f789::find_matches(…)。这意味着它使用了我们刚刚编写的库中的函数!

image.png

我们可以在之前使用find_matches地方做一个改造,并且功能也不会受影响。

fn main() -> Result<()> {
    // ....
    f789::find_matches(&content, &args.pattern, &mut std::io::stdout());
    //....
}
#[test]
fn find_a_match() {
    //....
    f789::find_matches("front\n789", "789", &mut result);
    // ...
}

黑盒测试

到目前为止,我们测试的主要发力点都是业务逻辑层面,这业务逻辑主要集中在find_matches函数中。

然而,有很多代码我们没有测试:也就是我们需要对外界(人机交互)部分做测试处理。想象一下,如果我们编写了main函数,但是意外地留下了一个硬编码的字符串,而不是使用用户提供的路径参数,会发生什么情况。我们也应该为这些写测试!(这种级别的测试通常称为黑盒测试系统测试)。

从本质上讲,我们仍然是在编写函数并使用#[test]进行注释。但是,我们会把这些测试代码放置到新目录中:tests/cli.rs。(按照约定,cargo将在tests/目录中查找集成测试)

回顾一下,f789是一个在文件中搜索字符串的小工具。我们已经测试了我们可以找到一个匹配项。让我们思考一下我们还可以测试的其他功能。

  • 文件不存在时会发生什么?
  • 当没有匹配项时输出是什么?
  • 当我们忘记一个(或两个)参数时,我们的程序是否会以错误退出?

这些都是有效的测试用例。

为了使这些测试更容易进行,我们将使用assert_cmd crate。它有许多很好的辅助功能,允许我们运行我们的二进制文件并查看它的行为。此外,我们还将添加predicates crate,它可以帮助我们编写断言,assert_cmd可以对其进行测试(并且具有出色的错误消息)。我们将这些依赖项添加到Cargo.tomldev dependencies部分,而不是主列表中。它们只在开发crate时需要,而在使用crate时不需要。


[dev-dependencies]
assert_cmd = "2.0.12"
predicates = "3.0.3"

我们直接进入并创建我们的tests/cli.rs文件:

下面,我们直接用代码注释来说明核心代码的功能

// 这个crate提供了在运行命令时添加方法的功能,通常用于编写命令行应用程序的测试。
use assert_cmd::prelude::*;
// 这个crate提供了编写断言(assertions)的功能,可以用来验证测试的预期结果。
use predicates::prelude::*;
// 这是Rust标准库中的模块,它允许你运行外部程序并与之交互。这通常用于测试执行外部命令时的行为。
use std::process::Command;
#[test]
fn file_doesnt_exist() -> Result<(), Box<dyn std::error::Error>> {
    // 这行代码创建了一个 Command 对象,它用于执行一个外部命令行程序。
    // cargo_bin 方法用于查找并返回通过 Cargo 构建的可执行文件。
    // 在这里,它尝试查找名为 "f789" 的可执行文件。
    let mut cmd = Command::cargo_bin("f789")?;
    // 这两行代码向命令添加了两个参数。
    // 它们模拟了在命令行中运行 "f789 front text.txt" 命令。
    cmd.arg("front").arg("text.txt");
    cmd.assert()
        .failure()
        .stderr(predicate::str::contains("无法读取文件"));
    Ok(())
}

我们可以使用cargo test运行此测试,就像我们之前编写的测试一样。第一次运行可能需要更长时间,因为Command::cargo_bin("f789")需要编译我们的main二进制文件。


生成测试文件

我们刚刚看到的测试仅检查当输入文件不存在时,我们的程序是否会写出错误消息。现在让我们测试一下我们是否确实会打印出我们在文件中找到的匹配项!

我们需要有一个文件,我们知道其内容,以便我们知道我们的程序应该返回什么,并在我们的代码中检查这个期望。

  • 一个想法是向项目中添加一个具有自定义内容的文件,并在我们的测试中使用它。
  • 另一个方法是在我们的测试中创建临时文件。

为了创建这些临时文件,我们将使用assert_fs crate。让我们将其添加到Cargo.toml中的dev-dependencies中:

assert_fs = "1.0.13"

这是一个新的测试案例(我们可以在其他测试案例下面编写),它首先创建一个临时文件(一个具名(named)文件,所以我们可以得到它的路径),然后用一些文本填充它,然后运行我们的程序,看看我们是否得到了正确的输出。当文件超出作用域时(在函数的末尾),实际的临时文件将自动被删除。

use assert_cmd::prelude::*;
use assert_fs::prelude::*;
use predicates::prelude::*;
use std::process::Command;
#[test]
fn find_content_in_file() -> Result<(), Box<dyn std::error::Error>> {
    let file = assert_fs::NamedTempFile::new("sample.txt")?;
    file.write_str("111\n222\n333\n4444 11")?;
    let mut cmd = Command::cargo_bin("f789")?;
    cmd.arg("11").arg(file.path());
    cmd.assert()
        .success()
        .stdout(predicate::str::contains("111\n4444 11"));
    Ok(())
}

运行cargo test,代码运行结果如下。

image.png

相关文章
|
1月前
|
Rust 安全 程序员
拜登:“一切非 Rust 项目均为非法”,开发界要大变天?
白宫国家网络总监办公室(ONCD,以下简称网总办)在本周一发布的报告中说道:“程序员编写代码并非没有后果,他们的⼯作⽅式于国家利益而言至关重要。”
34 1
|
2月前
|
Rust 前端开发 JavaScript
Rust在前端与全栈开发中的实践探索
随着Rust语言的日渐成熟,其应用场景已经从后端扩展到前端和全栈开发领域。本文将深入探讨Rust语言在前端与全栈开发中的实际应用案例,分析Rust语言在这些领域的优势和面临的挑战,并展望Rust未来的发展趋势。
|
4月前
|
存储 Rust 测试技术
Rust 开发命令行工具(中)(三)
Rust 开发命令行工具(中)(三)
|
4月前
|
存储 Rust JavaScript
Rust 开发命令行工具(中)(一)
Rust 开发命令行工具(中)(一)
|
4月前
|
存储 Rust Linux
Rust 开发命令行工具(上)(三)
Rust 开发命令行工具(上)(三)
|
4月前
|
存储 Rust Shell
Rust 开发命令行工具(上)(二)
Rust 开发命令行工具(上)(二)
|
4月前
|
存储 人工智能 Rust
Rust 开发命令行工具(上)(一)
Rust 开发命令行工具(上)(一)
|
存储 关系型数据库 Shell
使用 Rust 开发 PostgreSQL 存储过程
pgxr 使用 Rust 来编写 PostgreSQL 的扩展函数(相当于存储过程)。 项目地址:https://github.com/clia/pgxr 使用这个星球上最快的、高效、安全、有趣的编程语言,来为世界上功能最强大的开源关系数据库编写库内的程序! 试想,当你从数据库中查询出 1000 条记录用于程序处理时,当你的程序是数据库内的程序时,你根本无需将这 1000 条结果通过 PostgreSQL 的通讯协议走网络传输到应用程序里,在应用程序里分配这么大一块内存来装这些数据,再来进行处理。
7199 0
|
4天前
|
Rust 安全 程序员
|
4天前
|
Rust 安全 程序员
Rust vs Go:解析两者的独特特性和适用场景
在讨论 Rust 与 Go 两种编程语言哪种更优秀时,我们将探讨它们在性能、简易性、安全性、功能、规模和并发处理等方面的比较。同时,我们看看它们有什么共同点和根本的差异。现在就来看看这个友好而公平的对比。