本章内容:
- 编写一个派生宏来为结构体生成构建器。
- 创建白盒测试来验证宏中函数的行为。
- 使用黑盒测试从外部角度查看代码。
- 决定哪些类型的测试对你的宏最有用。
构建器模式是一种非常方便、流畅的构造结构体的方式。正因为如此,它在 Rust 代码中无处不在。然而,编写构建器所需的代码通常是模板化的——这种模板化的代码我们可以自动化处理!在本章中,我们将编写一个宏来实现这一点。由于我们不需要修改原始结构体,所以可以使用派生宏(记住,选择最简单的选项)。在实现块中,我们将创建一个临时的 Builder 结构体,用于存储信息,并提供一个 build 方法来创建原始结构体(见图 6.1)。
这不是一个完全原创的想法,完全出自我脑袋的光辉。例如,GitHub 上有一个程序宏工作坊,你可以在其中创建这种类型的宏(github.com/dtolnay/pro…),而 Jon Gjengset 的 Crust of Rust(www.youtube.com/watch?v=geo…)会带你实现一个可能的实现。因此,本章的重点并不在于原创性,而是在于实现这个构建器的过程中,讨论如何测试程序宏。理想情况下,这样的一章应该是使用测试驱动开发(TDD)来编写的。
注: 测试驱动开发(TDD)是一种将测试置于核心位置的编码方式。与其将测试视为事后补充,不如让每一段代码的编写都以测试为前提,来验证我们希望看到的行为。测试一开始是红色的(失败),因为还没有实现。当我们编写代码并且测试变成绿色(成功)后,我们会为下一个行为编写另一个测试。这种方法显然有一个优势:它能创造出具有优秀测试覆盖率的应用程序;支持者认为,TDD 还能推动开发人员编写更好的设计代码。
对我们来说,一种选择是从一个高层的测试开始,最终我们试图让它变成绿色。为了达到这个目标,我们会为期望的行为定义更小的单元测试。不幸的是,由于 TDD 依赖于小步增量的开发,这使得阅读(和编写)变得不那么愉快,而且会让本章比现在更长。所以,我们将从基本的设置开始,在进一步完善宏的过程中探索测试。之后,我们还会讨论在编写宏时,哪种类型的单元测试(黑盒测试或白盒测试)在实践中最有用。
6.1 构建器宏项目设置
在本章中,我们将使用稍微复杂一些的项目设置。我们不会将应用程序和宏分别放在一个目录中,而是将宏的部分拆分为一个简单的库,用于暴露宏本身,另一个库包含宏的实现。这种设置被认为是一个良好的实践,它将使用内建宏工具的宏函数和使用 proc_macro2 包装器(我们之前遇到过,并且像 quote 等库也在使用它)底层代码进行分离。虽然这是正确的做法,但也有其他方法来分隔和隔离代码——我们将在本章以及随后的章节中讨论这些方法——我认为这种方式通常是过度的。但展示这种方式并不是一个坏主意。之后,你可以决定这种隔离是否值得额外的设置(见图 6.2)。
我们需要执行以下步骤:
- 创建根目录,我们将其命名为
builder。 - 在此目录中,添加三个子目录:
builder-code、builder-macro和builder-usage。 - 前两个是库项目,因此使用命令
cargo init --lib来创建它们。最后一个是普通的(可执行的)Rust项目,使用cargo init创建。 - 现在通过另一个
cargo init命令将根目录设置为一个 Cargo 工作空间。 - 最后,修改
toml文件的内容,使其与下面的代码一致。
Listing 6.1 我们的 Cargo.toml 文件
[package] #1
name = "builder-code"
version = "0.1.0"
edition = "2021"
[dependencies]
quote = "1.0.33"
syn = { version = "2.0.39", features = ["extra-traits"] }
proc-macro2 = "1.0.69"
[package] #2
name = "builder-macro"
version = "0.1.0"
edition = "2021"
[dependencies]
builder-code = { path = "../builder-code" }
[lib]
proc-macro = true
[package] #3
name = "builder-usage"
version = "0.1.0"
edition = "2021"
[dependencies]
builder-macro = { path = "../builder-macro" }
[workspace] #4
members = [
"builder-macro",
"builder-code",
"builder-usage"
]
- #1 builder-code
- #2 builder-macro
- #3 builder-usage
- #4 builder
通过 Cargo 工作区,我们可以将三个子项目组合成一个更大的整体。虽然这不是绝对必要的,但它确实允许我们一次性运行所有子目录的检查和测试。
注: 有人编写了额外的工具(cargo-workspaces)来帮助你创建和管理工作区。
6.2 塑造我们设置的结构
现在,我们可以在 builder-code 的 lib.rs 中添加一些代码来创建构建器(辅助)结构体。
Listing 6.2 builder-code 中的 lib.rs 文件
use proc_macro2::{TokenStream};
use quote::{format_ident, quote, ToTokens};
use syn::DeriveInput;
pub fn create_builder(item: TokenStream) -> TokenStream {
let ast: DeriveInput = syn::parse2(item).unwrap(); #1
let name = ast.ident;
let builder = format_ident!("{}Builder", name); #2
quote! {
struct #builder {} #3
}
}
- #1 使用
parse2获取proc_macro2::TokenStream。 - #2 创建一个标识符来命名我们的构建器,重用接收的结构体的跨度(span)。
- #3 在输出中创建构建器结构体。
有敏锐眼光的读者可能已经注意到,我们的 builder-code 并没有被标记为过程宏。这是因为我们将宏暴露给另一个目录(builder-macro)。这意味着我们不再可以访问 proc_macro 类型,因为这些只在标记为过程宏的库内部可用。相反,我们使用 proc_macro2 包装器。由于使用了这个包装器,我们转而使用 parse2 进行解析,并解包 Result。
和往常一样,我们在解析后获取结构体的名称。在这种情况下,我们这样做是因为我们希望有一个辅助结构体来暂时存储字段值。由于我们将在用户代码中注入此构建器,我们不能简单地将其命名为 “Builder”,因为与用户代码的命名冲突的可能性太大。此外,每个模块中只能使用一次这个宏,否则会生成两个名为 “Builder” 的结构体。因此,我们使用被注解结构体的名称作为前缀。这样做应该是相对安全的,但如果你想更有把握,Rust 文档建议添加下划线前缀(__),这使得与遵循标准命名的代码发生冲突的可能性非常小。
实际上,这是在许多宏代码中遇到的一种约定,我们在一些实际应用的示例中已经看到了。如果你不相信我,可以翻回前几章看看。不过,只有在你不希望结构体被宏用户直接使用时才使用这种方式。如果我们希望他们在应用代码中传递构建器,我们希望命名既可预测,又遵循 Rust 的命名约定。而虽然双前缀不会生成警告,但很少有人在应用代码中为结构体命名使用双前缀。
为了为我们的辅助构造函数创建标识符,我们需要生成正确的字符串,并将其与跨度一起传递到标识符构造函数中。我们可以将这两行代码委托给 format_ident,它将处理所有的工作。对于跨度,它选择最终接收到的标识符的跨度,并回退到 call_site(“当前位置”——这是我们在上一章使用的);如果没有,它将使用构建该结构体的结构的跨度。完美。
一旦我们有了名称,我们返回一个空的结构体作为占位符代码。由于我们的代码现在返回的是 proc_macro2::TokenStream,我们不再需要将 quote 的结果转换为过程宏预期的标准 TokenStream,或者至少在这部分代码中不需要。
现在,让我们来看一下 builder-macro 代码。
Listing 6.3 实际宏(在 builder-macro 中)
use proc_macro::TokenStream;
use builder_code::create_builder;
#[proc_macro_derive(Builder)]
pub fn builder(item: TokenStream) -> TokenStream {
create_builder(item.into()) #1
.into() #2
}
- #1 将普通的
TokenStream转换为proc_macro2::TokenStream。 - #2 将
proc_macro2::TokenStream结果转换为标准的TokenStream,这样我们就可以返回它。
宏的定义与之前的类似,只是现在它在一个单独的包中。我们定义了一个名为 Builder 的 derive 宏,它接受一个普通的 TokenStream 作为参数,并返回另一个 TokenStream。因为我们的 builder-code 只使用 proc_macro2,所以当我们将数据传递给 builder-code 时,我们需要进行“转换”。当我们获取结果时,必须执行相同的操作,因为过程宏并不知道也不关心包装器;它们只期望一个“标准”的流。在这两种情况下,我们都可以依赖 Into 特性来处理转换。
最后,我们将在 builder-usage 中填充主文件。目前,它只是将宏添加到一个名为 Gleipnir 的空结构体中。
Listing 6.4 builder-usage 的 main.rs 文件和空示例结构体
use builder_macro::Builder;
#[derive(Builder)]
struct Gleipnir {}
fn main() {}
这只是一个简单的方法,用于验证所有内容是否能够编译。这个任务最好通过测试来完成,测试不仅验证编译,还验证实际的行为。接下来我们将讨论这个话题。
6.3 添加白盒单元测试
我们可以用两种方式对我们的过程宏进行单元测试。第一种是使用“内部”或白盒测试,我们可以访问代码的内部结构。另一种是黑盒测试,我们从外部视角来看待代码。我们将首先从白盒测试开始。
在 Rust 中,测试内部代码比大多数编程语言要容易,因为我们可以——并且应该——将测试添加到包含实现的文件中。这使得我们可以验证任何行为,包括隐藏在私有结构体、字段和函数中的行为。我们的第一个测试将添加到 builder-code 的 lib.rs 中。如果我们的宏很大,我们还会将测试添加到其他文件中。
第一个测试,如 Listing 6.5 所示,是一个非常基础的断言。由于我们已经写了代码来生成结构体,我们可以断言我们得到了预期的构建器名称。例如,当我们传入一个名为 StructWithNoFields 的结构体时,我们期望返回的 token stream 包含 StructWithNoFieldsBuilder。
Listing 6.5 我们的 builder-code 和第一个基础测试
// imports
pub fn create_builder(item: TokenStream) -> TokenStream {
let ast: DeriveInput = syn::parse2(item).unwrap();
let name = ast.ident;
let builder = format_ident!("{}Builder", name); #1
quote! {
struct #builder {}
}
}
#[cfg(test)]
mod tests { #2
use super::*;
#[test]
fn builder_struct_name_should_be_present_in_output() {
let input = quote! { #3
struct StructWithNoFields {}
};
let actual = create_builder(input);
assert!(actual.to_string()
.contains("StructWithNoFieldsBuilder")); #4
}
}
#1 记住,这里我们正在创建构建器的名称(标识符)。
#2 我们的测试模块位于与生产代码相同的文件中。
#3 使用 `quote` 来生成输入
#4 我们将输出转换为字符串,并运行一个简单的断言:它是否包含预期的名称?
这非常基础,但它已经为我们的代码提供了一些保障。例如,我们现在知道代码在其输出中返回了构建器名称。那么,检查整个输出是否符合我们的预期会不会更好呢?我们可以通过将输出与我们自己的 TokenStream 进行比较来做到这一点。这意味着我们需要手动构建期望的输出,而这有几种方法可以做到。例如,我们可以创建一个新的 TokenStream,并使用 to_tokens 将所有期望的输出添加到该流中。虽然这种方法可行,但它需要比另一种方法更多的工作:直接使用 quote 生成期望的输出。
Listing 6.6 添加第二个测试
#[test]
fn builder_struct_with_expected_methods_should_be_present_in_output() {
let input = quote! { #1
struct StructWithNoFields {}
};
let expected = quote! {
struct StructWithNoFieldsBuilder {}
};
let actual = create_builder(input);
assert_eq!( #2
actual.to_string(),
expected.to_string()
);
}
#1 使用 `quote` 创建输入和期望的输出
#2 断言期望和实际的输出字符串是否相同
由于 TokenStream 没有实现 PartialEq,我们不能直接比较两者。将它们转换为字符串并比较结果是一个足够简单的解决方法。
我们也可以将输出转换为方便验证的格式,而不是确保期望的值与输出类型相同。所以第三种选择是使用 parse2。如果解析失败,测试会因为 unwrap 调用而正确地 panic。如果成功,我们得到了一个 AST,可以轻松地从中提取信息——并验证它是否符合预期。
Listing 6.7 添加第三个测试
#[test]
fn assert_with_parsing() {
let input = quote! {
struct StructWithNoFields {}
};
let actual = create_builder(input);
let derived: DeriveInput = syn::parse2(actual).unwrap(); #1
let name = derived.ident;
assert_eq!( #2
name.to_string(),
"StructWithNoFieldsBuilder"
);
}
#1 我们将结果解析为 `DeriveInput`,如果解析失败则 panic。
#2 成功后,我们可以检索解析结果的属性,看看它们是否符合我们的预期。
这非常强大,但一旦我们开始输出多个项/结构体/函数时,我们就需要为解析编写自定义逻辑。当这变得过于复杂时,我们可能不得不测试我们的测试。这就是为什么第二种变体(使用 quote)是我最喜欢的三种方法的原因,既清晰又——尤其是——易用。
除了测试 token 流输出外,白盒测试在验证辅助函数的输出时也非常有用。由于你应该熟悉 Rust 中的测试基础,相关测试的示例在这里就不再展示了。
尽管白盒测试在多种场景中非常有用,但单独使用它们不足以完全测试宏。是的,它们可以告诉我们代码输出是否符合预期。但我们真正想知道的是生成的代码是否能做我们希望它做的事情。在这种情况下,它是否能允许我们创建结构体?为了验证这种行为,我们需要采纳外部视角。
6.4 黑盒单元测试
如前所述,黑盒测试从外部视角进行。它们只关心代码是否能产生所需的结果,而不关心它是如何实现的。这通常是一个好主意,因为代码的价值在于它能产生什么,而不是它是如何实现的。我们是否真的关心在构建结构体时,值是否临时存储在某个地方?验证这一点重要吗?由于其方法,黑盒测试比白盒测试更不依赖于实现细节。这意味着,当你只是修改代码的内部实现时,黑盒测试更不容易发生变化。
6.4.1 一条快乐路径测试
我们从一些“快乐路径”测试开始,验证代码在一切按计划进行时是否正常工作。我们将测试添加到 builder-usage 中,因为这个 crate 是我们宏的使用者之一。或者,我们也可以将所有内容添加到 builder-usage 根目录下的新 tests 文件夹中。在普通项目中,这是黑盒测试的一个好地方,因为 tests 中的代码没有特权访问——它只能使用项目的公共 API。但在我们的情况下,正在测试的代码在完全不同的项目中。因此,main.rs 是可以的。
我们的第一个最基本的测试仅验证代码是否能成功编译,除此之外什么也不做。
Listing 6.8 第一个黑盒测试(在 builder-usage 中,main.rs)
use builder_macro::Builder;
fn main() {}
#[cfg(test)]
mod tests {
#[test]
fn should_generate_builder_for_struct_with_no_properties() {
#[derive(Builder)]
struct ExampleStructNoFields {} #1
let _: ExampleStructNoFields = ExampleStructNoFields::builder()
.build(); #2
}
}
#1 当我们创建一个用 #[derive(Builder)] 注解的结构体时...
#2 我们期望宏会为其创建一个 builder 函数。
我们的测试定义了一个用宏注解的结构体,并且我们断言调用 ::builder().build() 会返回该结构体的一个实例。通过这样做,我们在断言代码会编译,证明我们期望的函数已被生成。即使没有断言,代码也会报告错误:function or associated item builder not found for this struct。因此,让我们通过为这个初始用例添加所需的一切来让测试变绿。
Listing 6.9 添加足够的实现使测试通过
// earlier imports
pub fn create_builder(item: TokenStream) -> TokenStream {
let ast: DeriveInput = syn::parse2(item).unwrap();
let name = ast.ident;
let builder = format_ident!("{}Builder", name);
quote! {
struct #builder {} #1
impl #builder { #2
pub fn build(&self) -> #name {
#name {}
}
}
impl #name {
pub fn builder() -> #builder { #3
#builder {}
}
}
}
}
#1 我们之前已经有了这个构建器结构体。
#2 但现在它有了实现,包含一个 `build` 方法,返回一个注解的结构体实例。
#3 原始结构体现在有了一个方法,返回构建器结构体。
所有更改都位于 quote 宏中。我们不再生成一个空结构体,而是为构建器和原始结构体都添加了实现块。在这些块中,我们定义了在测试中调用的两个函数。
在编写这段代码时,你可能会遇到一些错误,具体如下:
expected value, found struct ExampleStructNoFields—— 这个错误是因为忘记在build方法内的#name后加上{}。错误信息有帮助,但——像往常一样——它指向的是宏,而不是具体行。定位这个错误的一种方法是查找你使用#name的地方,因为你知道它的值是错误信息中出现的ExampleStructNoFields。你也可以使用cargo expand来查看生成的代码。这个命令稍有不同,因为现在是扩展测试——在这种情况下,运行cargo expand --tests --bin builder-usage,你可以在builder-usage目录下运行它。expected () because of default return type—— 这个错误不太清楚,直到你意识到你有一些函数应该返回东西。所以你可能忘记指定返回类型(例如-> #name)。expected identifier, found "ExampleStructNoFieldsBuilder"—— 如果你忘记了需要标识符而不是字符串来命名结构体,会出现这个错误。你可能还记得我们在前面章节中遇到过类似的问题。cannot find value build in this scope——quote内部的拼写错误意味着我们有一个与未知变量结合使用的哈希符号。Rust 不喜欢这样。因此,检查那些不匹配任何内容的哈希符号。IDE 可能会为这些哈希符号提供自动完成功能,帮助你追踪或避免这个问题。
我们已经通过了第一个快乐路径测试——可以称之为“编译测试”!这是一个良好的第一步。即使代码没有做我们预期的事情,至少它验证了生成的代码不会崩溃。需要注意的是,这些实现更改会导致第二个白盒测试失败,因为我们现在生成了更多的输出。你可以修复它或暂时禁用它。我还建议暂时禁用第三个(解析)测试,因为,正如预测的那样,它现在需要更多的自定义解析工作。与此同时,第一个白盒测试表明简单性是有价值的:它一直在工作。
6.4.2 带有实际属性的快乐路径测试
第二个有用的测试是什么?一个包含单个属性的结构体怎么样?
Listing 6.10 测试生成带有一个属性的结构体构建器(仍在 main.rs 中,builder-usage)
#[test]
fn should_generate_builder_for_struct_with_one_property() {
#[derive(Builder)]
struct Gleipnir {
roots_of: String,
}
let gleipnir = Gleipnir::builder()
.roots_of("mountains".to_string())
.build();
assert_eq!(gleipnir.roots_of, "mountains".to_string());
}
这个测试会失败,并报告找不到 roots_of 方法。我们需要做什么来修复这个问题?我们需要为结构体的每个字段暴露一个方法来保存字段值。我们的 build 方法将使用这些保存的信息来创建结构体(在这个例子中是 Gleipnir)。关联的构建器函数将创建初始的构建器,就像之前一样,但现在我们需要确保构建器为所有必需的字段定义属性以保存值。看起来我们需要获取结构体的字段,以便进行操作。更具体地说,我们需要知道它们的名称和类型,以生成相应的函数。
注意:我们遵循的是 TDD(测试驱动开发)哲学,尽量编写足够的代码使测试变绿。所以现在假设我们只需要处理 String 类型是可以的。我们将在下一节中使代码更加通用和高效。
Listing 6.11 builder-code 实现(没有白盒测试和导入)
pub fn create_builder(item: TokenStream) -> TokenStream {
// AST,获取名称并创建构建器标识符
let fields = match ast.data {
Struct(
DataStruct {
fields: Named(
FieldsNamed {
ref named, ..
}), ..
}
) => named,
_ => unimplemented!(
"only implemented for structs"
),
};
let builder_fields = fields.iter().map(|f| {
let field_name = &f.ident;
let field_type = &f.ty;
quote! { #field_name: Option<#field_type> }
});
let builder_inits = fields.iter().map(|f| {
let field_name = &f.ident;
quote! { #field_name: None }
});
let builder_methods = fields.iter().map(|f| {
let field_name = &f.ident;
let field_type = &f.ty;
quote! {
pub fn #field_name(&mut self, input: #field_type) -> &mut Self {
self.#field_name = Some(input);
self
}
}
});
let set_fields = fields.iter().map(|f| {
let field_name = &f.ident;
let field_name_as_string = field_name
.as_ref().unwrap().to_string();
quote! {
#field_name: self.#field_name.as_ref()
.expect(
&format!("field {} not set", #field_name_as_string))
.to_string()
}
});
quote! {
struct #builder {
#(#builder_fields,)*
}
impl #builder {
#(#builder_methods)*
pub fn build(&self) -> #name {
#name {
#(#set_fields,)*
}
}
}
impl #name {
pub fn builder() -> #builder {
#builder {
#(#builder_inits,)*
}
}
}
}
}
这段代码较为复杂,让我们逐步分析。
首先,我们通过解析来获取传入结构体的字段,就像我们之前做的几次一样。一旦获取了字段信息,我们将这些字段用于四种不同的方式。
首先,确保我们的辅助结构体具有与原始结构体相同的属性,这样我们就可以临时存储所有字段信息。但是,当我们第一次创建这个辅助结构体时(调用 builder()),我们没有任何值需要存储。因此,我们将字段的类型包装在 Option 中。(我们将忽略处理 Option 类型本身的问题。)
接下来,当调用 builder() 时,我们初始化字段,将每个字段设置为 None,因为此时我们还没有值。
然后,我们想要为每个字段生成公共方法,这些方法的名称与字段相同,用于将该字段设置为 None 以外的值。这些方法将接受与字段相同类型的参数,并将其作为 Option 类型存储。最后,生成的构建器结构体将会有一个流畅的 API:builder().first_field(…).second_field(…).build()。
最后,当我们调用 build 方法时,我们想要取回所有存储的值并创建标记的结构体。在这部分代码中,我们为每个字段生成初始化代码。由于我们处理的是 Option 类型,我们使用 expect 来获取实际的值,用户如果忘记设置某个字段,操作可能会失败。所以我们会返回一个有用的错误信息。
这些是需要组合在 quote 中的各个部分。如果你有困难理解这些,运行 cargo expand 来查看测试的展开结果,或参阅图 6.3。
Listing 6.16 最终输出
quote! {
struct #builder {
#(#builder_fields,)* #1
}
impl #builder {
#(#builder_methods)* #2
pub fn build(&self) -> #name { #3
#name {
#(#set_fields,)*
}
}
}
impl #name {
pub fn builder() -> #builder { #4
#builder {
#(#builder_inits,)*
}
}
}
}
#1 构建器结构体与注解的结构体具有相同的字段,但类型被包装在 `Option` 中。
#2 它为每个字段提供了一个设置值的方法。
#3 它的 `build` 方法可以用来创建结构体。
#4 为了创建一个新的构建器,所有字段都初始化为 `None`,我们为原始结构体添加了 `builder()` 方法。
这就是实现的核心内容,通过组合这些片段,我们可以生成最终的构建器结构体。
在编写这段代码时,我遇到了一个令人烦恼的错误:
22 | #[derive(Builder)]
| ^^^^^^^
| |
| item list starts here
| non-item starts here
| item list ends here
打印信息和展开并没有给我带来什么有价值的见解。我怀疑问题出在我的“构建器方法”上,并通过暂时禁用那部分代码确认了这一点。然后我突然意识到,我写了 #(#builder_methods,)* ——而函数是不能用逗号分隔的!去掉逗号后问题就解决了。
图6.3展示了我们代码各个部分生成的输出概览,以及它们如何组合在一起。从一个简单的具有单个字段的结构体开始,我们生成了一个构建器,其中包含一个属性、初始化方法、设置该字段的方法,以及一个 build 方法,用于创建原始的 Gleipnir 结构体。
6.4.3 测试促使重构
前面的代码远非完美。但好消息是,测试使得重构变得更容易且更安全。只要黑盒测试保持绿色,我们就知道我们的行为仍然是一样的,尽管目前只针对空结构体和带有一个字段的结构体进行测试。现在,利用这种自由进行重构吧。到目前为止,我们通常把所有代码放在一个文件和一个函数中。虽然这样对于简单的例子来说足够了,但现在这段代码已经比较大——一个函数包含了大约70行代码。如果我们期望实现继续增长,那么拆分代码是有意义的。
理想的情况是,重构是一步步进行的,每完成一个小步骤,我们都应该验证代码是否仍然正常工作。我们可以从提取一些函数开始。每个函数遍历字段并执行一件事(字段定义、初始化等)。借助IDE工具,这是一件轻松的事情。
Listing 6.17 重构后的 create_builder 函数代码片段
let builder_fields = builder_field_definitions(fields);
let builder_inits = builder_init_values(fields);
let builder_methods = builder_methods(fields);
let original_struct_set_fields = original_struct_setters(fields);
测试依然通过。检索字段的名称和类型是我们在 builder_field_definitions 和 builder_methods 中都要做的事情,因此我们可以进行去重。下面的代码展示了如何创建一个辅助函数以及它如何在另一个函数中使用。
Listing 6.18 为获取字段名称和类型添加一个辅助函数
use syn::{Ident, Type}; // 以及其他的导入
fn builder_field_definitions(fields: &Punctuated<Field, Comma>)
-> impl Iterator<Item = TokenStream2> + '_ {
fields.iter().map(|f| {
let (name, f_type) = get_name_and_type(f); #1
quote! { pub #name: Option<#f_type> }
})
}
fn get_name_and_type<'a>(f: &'a Field) -> (&'a Option<Ident>, &'a Type) {
let field_name = &f.ident;
let field_type = &f.ty;
(field_name, field_type)
}
#1 从辅助函数获取字段的名称和类型
在此过程中,我们还需要添加生命周期来确保正确性。这是因为生命周期消除规则不能应用于具有两个输出的函数。在任何情况下,测试仍然通过。
接下来,单个文件中的代码太多了,我们应该将一些功能拆分出去。我们可以有两种方式来拆分代码。首先,我们可以创建一个子目录(可能叫做“implementation”?——命名真是个难题),然后添加一个文件并用 mod.rs 来决定导出什么内容(从而可以在我们的根库文件中使用)。这种方法的优势在于它提供了更多的灵活性和能力——虽然我们目前不需要这些——但需要付出一些努力。第二种方法是将辅助函数放在一个单独的文件中,这种方法是更“现代”的做法(尽管目录 + mod.rs 仍然广泛使用)。
Listing 6.19 新的 fields.rs 文件的一部分
// 导入
pub fn original_struct_setters(fields: &Punctuated<Field, Comma>)
-> impl Iterator<Item = TokenStream2> + '_ {
fields.iter().map(|f| {
let field_name = &f.ident;
let field_name_as_string = field_name.as_ref()
.unwrap().to_string();
quote! {
#field_name: self.#field_name.as_ref()
.expect(
&format!("field {} not set", #field_name_as_string))
.to_string()
}
})
} #1
// 其他三个函数
fn get_name_and_type<'a>(f: &'a Field) -> (&'a Option<Ident>, &'a Type) {
let field_name = &f.ident;
let field_type = &f.ty;
(field_name, field_type)
} #2
#1 这是四个函数中的一个,它现在是公开的(pub),所以我们可以在其他地方使用它
#2 这个辅助函数保持私有,因为它只在这个文件中的函数中使用。
Listing 6.20 在 lib.rs 中使用这些函数
mod fields; #1
use crate::fields::{
builder_field_definitions, #2
builder_init_values,
builder_methods,
original_struct_setters
};
// 其他导入和代码
#1 使用我们的文件
#2 导入我们需要的函数
将与字段相关的代码移到另一个文件后,我们的库变得更加清晰,并且隐藏了一个只对与字段相关的函数有意义的辅助函数(get_name_and_type)。这样做很好:更多的信息隐藏和更少的“表面”区域。将单一的大函数拆成多个小函数也为额外的单元测试打开了大门。以下列出了我们助手函数的测试示例。
Listing 6.21 get_name_and_type 的一个测试示例
#[test]
fn get_name_and_type_give_back_name() {
let p = PathSegment {
ident: Ident::new("String", Span::call_site()),
arguments: Default::default(),
};
let mut pun = Punctuated::new();
pun.push(p);
let ty = Type::Path(TypePath {
qself: None,
path: Path {
leading_colon: None,
segments: pun,
},
});
let f = Field {
attrs: vec![],
vis: Visibility::Inherited,
mutability: FieldMutability::None,
ident: Some(Ident::new("example", Span::call_site())),
colon_token: None,
ty,
};
let (actual_name, _) = get_name_and_type(&f);
assert_eq!(
actual_name.as_ref().unwrap().to_string(),
"example".to_string()
)
}
如你所见,构造这个函数的参数需要大量的样板代码。而且在这种情况下,我们通过 quote + parse2 的技巧无法帮助我们,因为 Field 并没有实现 Parse。所以在这个级别上,可能会决定放弃白盒测试。但如果需要,它是可用的。
6.4.4 进一步改进与测试
在完成了第一次重构之后,我们应该专注于实现,因为目前我们只支持字符串类型。我们可以通过测试验证这种行为,正如那些真正实践TDD的开发者所做的那样。
Listing 6.22 一个包含两个属性的测试,其中一个不是字符串类型
#[test]
fn should_generate_builder_for_struct_with_two_properties() {
#[derive(Builder)]
struct Gleipnir {
roots_of: String,
breath_of_a_fish: u8
}
let gleipnir = Gleipnir::builder()
.roots_of("mountains".to_string())
.breath_of_a_fish(1)
.build();
assert_eq!(gleipnir.roots_of, "mountains".to_string());
assert_eq!(gleipnir.breath_of_a_fish, 1);
}
正如预期的那样,这在编译时会失败(错误信息为:expected u8, found struct String),因为我们在值上执行了 to_string():
quote! {
#field_name: self.#field_name.as_ref()
.expect(&format!("field {} not set", #field_name_as_string))
.to_string()
}
有几种方法可以解决这个问题。一种非常简单的解决方案是将 to_string() 替换为 clone(),这样就可以让 String、原始类型以及所有实现了 Clone 的结构体自动生效。如果我们收到一个没有实现 Clone 特性的字段,编译器会报错提示(例如:YourCustomStruct does not implement Clone)。这种方法很好,但它要求你为每个结构体属性实现 Clone,这可能不是你想要的。
还有两种其他的解决方案。一个需要额外的代码,并且功能有限;另一个更简洁优雅,但需要我们提前更多地考虑目标和 Rust 的规则。(显然,第一个方案是我首先想到的,直到我遇到第二个解决方案,它通过消费 builder 来解决这个问题)。那么为什么我们要探讨第一个方案呢?毕竟它比当前的实现还要差?原因是:在一本关于宏的书中,第一种解决方案的优势是它要求我们深入了解 AST!
目前解决方案的一个问题是,to_string() 只适用于一种类型。像 u8 这样的原始类型并不需要类似的操作,因为它们实现了 Copy 特性。这意味着我们可以检查接收到的类型,当我们收到一个原始类型或不是 String 类型时,跳过 to_string() 这部分。
为此,我们需要一个辅助函数,它可以告诉我们某个类型是否是 String。那么,如何在类型中找到这部分信息呢?因为 Type 是一个较大的枚举类型,我们可以打印出在测试中得到的 AST 部分:
Path(TypePath {
qself: None,
path: Path {
leading_colon: None,
segments: [
PathSegment {
ident: Ident {
ident: "String",
span: #0 bytes(226..232),
},
arguments: PathArguments::None,
},
],
},
})
因此,我们的 Type 包含一个 Path 和 TypePath,其中 path.segments 包含一个 ident(见图 6.4)。而 ident 可以转换成一个字符串。
这意味着我们可以编写一个辅助函数来获取标识符,将其转换为字符串,并将其与类型的字符串表示进行比较。正如你所看到的,编写这段代码的关键是弄清楚在哪里查找有用的数据。
Listing 6.23 添加类型辅助函数到 fields.rs
fn matches_type(ty: &Type, type_name: &str) -> bool {
if let Type::Path(ref p) = ty { #1
let first_match = p.path.segments[0].ident.to_string();
return first_match == *type_name; #2
}
false
}
#1 只有在我们遇到 Type::Path 时,才会进一步解析并将标识符作为字符串与 type_name 进行比较。 #2 将标识符作为字符串与 type_name 参数(类型的字符串表示)进行比较。
接下来,我们将在 fields.rs 中使用这个辅助函数。我们要做的是,当遇到字符串类型时,给我们的 TokenStream 添加 as_ref 和 to_string 调用,否则我们假设我们处理的是实现了 Copy 特性的原始类型,这意味着我们不需要担心所有权转移的问题。
Listing 6.24 根据 matches_type 结果产生不同的输出
pub fn original_struct_setters(fields: &Punctuated<Field, Comma>)
-> impl Iterator<Item = TokenStream2> + '_ {
fields.iter().map(|f| {
let (field_name, field_type) = get_name_and_type(f);
let field_name_as_string = field_name.as_ref()
.unwrap().to_string();
if matches_type(field_type, "String") { #1
quote! {
#field_name: self.#field_name.as_ref() #2
.expect(
&format!(
"field {} not set", #field_name_as_string)
).to_string()
}
} else {
quote! {
#field_name: self.#field_name #3
.expect(
&format!(
"field {} not set", #field_name_as_string)
)
}
}
})
}
#1 检查类型是否为 String #2 如果类型是 String,我们保留原来的 quote 输出。 #3 在其他情况下,我们不需要 as_ref 和 to_string 调用,因为原始类型可以被复制。
再次运行我们的最后一个测试,所有测试都会变为绿色。太棒了。但是代码中有一些重复,如果你不喜欢这些重复,我们可以消除它。下面的示例(在 Listing 6.25 中)避免了一些重复,尽管它也更难以阅读。这是一个权衡。
Listing 6.25 通过组合 TokenStream 片段来避免重复
pub fn original_struct_setters(fields: &Punctuated<Field, Comma>)
-> impl Iterator<Item = TokenStream2> + '_ {
fields.iter().map(|f| {
let (field_name, field_type) = get_name_and_type(f);
let field_name_as_string = field_name.as_ref()
.unwrap().to_string();
let error = quote!(
expect(&format!("Field {} not set", #field_name_as_string))
);
let handle_type = if matches_type(field_type, "String") {
quote! {
as_ref()
.#error
.to_string()
}
} else {
quote! {
#error
}
};
quote! {
#field_name: self.#field_name.#handle_type
}
})
}
这是完全由 TokenStream 组成的!正如我们之前所说:quote 提供了一些非常灵活的方式来组合输出。所以不要认为单个函数需要返回一块完整的、可运行的生成代码。完全可以构建一些小的 TokenStream 片段,这些片段会在最终的输出中拼接在一起。或者你也可以忽略那些不相关的部分——但稍后我们会详细讲解。
6.4.5 另一种方法
我们的工作还没有完成,特别是当你开始尝试使用其他非原始类型的属性时,你会发现目前的实现存在一些问题。记住,我们现在是通过检查字段是否是 String 类型,并且在不是 String 时依赖 Copy。但是,现实中有许多其他类型(也许)是可以克隆的,但并不实现 Copy 特性。那么,让我们增加一个新的测试,看看 Vec<String> 是否能与我们的代码兼容。
Listing 6.26 测试具有额外属性(不是 Copy 类型)的情况
#[test]
fn should_generate_builder_for_struct_with_multiple_properties() {
#[derive(Builder)]
struct Gleipnir {
roots_of: String,
breath_of_a_fish: u8,
other_necessities: Vec<String>, #1
}
let gleipnir = Gleipnir::builder()
.roots_of("mountains".to_string())
.breath_of_a_fish(1)
.other_necessities(vec![
"sound of cat's footsteps".to_string(),
"beard of a woman".to_string(),
"spittle of a bird".to_string()
])
.build();
assert_eq!(gleipnir.roots_of, "mountains".to_string());
assert_eq!(gleipnir.breath_of_a_fish, 1);
assert_eq!(gleipnir.other_necessities.len(), 3)
}
#1 我们将测试 Vec<String> 是否与我们的代码兼容。
如预期所示,编译时会失败,并出现错误信息:move occurs because self.vec_value has type Option<Vec<String>>, which does not implement the Copy trait,因为这是一个既不是 String 也不是 Copy 类型的字段。那么,也许我们应该重写代码,克隆所有不是 Copy 的类型?这确实是让我们的当前测试变绿的一种解决方法。虽然这样会在宏中增加更多代码,但它会生成比我们最初的“克隆所有东西”的解决方案更少的输出代码,而且性能稍微好一些。不过,这也将限制我们的构建器宏只能处理 Clone 或 Copy 类型,而不是所有遇到的类型都符合这两者之一。
但还有另一种更少限制的方法。我们的构建器是一个临时结构体——在我们想要的结构体不完整时用来保存数据的。所以,避免移动(move)其实是一个不必要的限制。最有效的处理构建器的方式可能是消费它。如何实现这一点?答案是:通过消费 self,而不是借用它的(可变)引用。(这些更改会再次使我们的白盒测试失败。你可以通过一些小修复使其重新运行,但我们这里不再展示。)
Listing 6.27 停止借用构建器结构体(lib.rs)
pub fn build(self) -> #name { #1
#name {
#(#set_fields,)*
}
}
#1 使用 `self` 进行消费,代替了借用 `&self`。
Listing 6.28 停止借用构建器结构体(fields.rs)
pub fn #field_name(mut self, i: #field_type) -> Self { #1
self.#field_name = Some(i);
self
}
#1 这里的参数 `self` 不再是引用,返回值也不再是可变引用。
通过几个小的更改,代码能够编译通过。但我们还没完成:如果一切都被移动而不是复制,我们就不再需要将 String 当作特殊类型来处理了。与此同时,使用 format 来生成错误信息并不是理想的,因为它会在运行时完成部分工作(如果你不相信我,可以展开代码查看)。在前面的章节中,我们使用了 stringify 宏来避免某些运行时工作。这一次,我们转而使用 concat 宏,这是一个可以连接字符串字面量的宏,它可以帮助我们生成合适的消息。
Listing 6.29 简化 original_struct_setters
pub fn original_struct_setters(fields: &Punctuated<Field, Comma>)
-> impl Iterator<Item = TokenStream2> + '_ {
fields.iter().map(|f| {
let field_name = &f.ident;
let field_name_as_string = field_name
.as_ref().unwrap().to_string();
quote! {
#field_name: self.#field_name
.expect(
concat!("field not set: ", #field_name_as_string), #1
)
}
})
}
#1 使用 `concat` 来生成 panic 消息,替代了之前的 `format`。
这个消费方法的唯一缺点是,构建器实例被消费,这意味着如果你想要重用它,必须进行克隆。除此之外,我们的最新解决方案更简洁、更优雅,性能也应该比之前更好,而且不再需要 Clone,因为我们正在移动值而不是复制它们。
声明一下,这不是我的专业领域:像复制一样,移动操作在后台实际上是 memcpy——基本上是一个复制字节缓冲区的系统函数,通常是一个非常便宜的操作。大多数情况下,这些 memcpy 会被编译器优化掉,但这并不保证。如果没有优化掉,这些复制操作对于小类型或只包含指针加“书籍管理”内容的类型来说不是问题,所有其他内容都存储在堆上。另一方面,完全存储在栈上的大型类型复制会比较昂贵,这可能导致性能问题。因此,测量性能非常重要,因为现代计算机架构会嘲笑我们简化事物的努力(“移动总是最好的选择”)。
6.4.6 不愉快的路径
在“不愉快路径”测试中,我们希望检查失败模式。对于我们的 Rust 宏,我们关注的是运行时和编译时的失败。在第一个类别中,我们的代码在字段缺失时应该会 panic。我们从验证这一行为开始。
Listing 6.30 测试缺少字段时的 panic
#[test]
#[should_panic] #1
fn should_panic_when_field_is_missing() {
#[derive(Builder)]
struct Gleipnir {
_roots_of: String, #2
}
Gleipnir::builder().build();
}
#1 我们期望会发生 panic。 #2 我们的结构体有一个字段,但我们在调用 build 之前没有设置它。
#[should_panic] 告诉 Rust,我们期望这段代码会 panic,因为我们在没有为字段设置值的情况下调用了 build。这覆盖了我们在运行时 panic 的唯一情况。但我们也在编译时发生 panic——例如,当我们接收到一个非结构体类型时。我们应该验证这一行为。为此,我们需要将 trybuild crate 添加到我们的依赖中。运行 cargo add --dev trybuild 或在 builder-usage 中的 Cargo.toml 开发依赖中添加 trybuild = "1.0.85"。然后,在 builder-usage 根目录下添加一个 tests 文件夹,并创建一个名为 compilation_tests.rs 的文件,内容如下所示。
Listing 6.31 我们的编译测试运行器(在 tests 文件夹中)
#[test]
fn should_not_compile() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/fails/*.rs"); #1
}
#1 trybuild 将查看指定目录(`tests/fails`)中的测试,验证它们是否在编译时失败。
trybuild 会验证所选目录中的所有内容是否编译失败。当你第一次运行给定测试时,它会输出收到的错误信息(如果有的话)。如果错误信息符合预期,你可以将生成的输出文件添加到你的测试文件夹中。这种方法非常有意义:由于很多傻乎乎的原因都可能导致编译失败。你希望确保它因正确的原因失败。
目前,我们没有任何失败的测试。创建一个 fails 目录,并将 build_enum.rs 文件放入其中,内容如下所示。
Listing 6.32 测试将宏应用于枚举时是否会失败
use builder_macro::Builder;
#[derive(Builder)]
pub enum ExampleEnum {} #1
fn main() {} #2
#1 由于这是一个枚举类型,而我们不处理枚举,因此我们期望编译失败。
#2 `trybuild` 需要一个 `main` 函数,尽管它可以是空的。
我们将宏应用于一个枚举,这应该会失败,因为我们只支持带有字段的结构体。当你运行测试时,你将得到以下错误信息,以及在项目中的 wip 目录中生成一个失败文件。你可以将该文件复制到 fails 文件夹,或者将错误信息添加为文件并命名为 build_enum.stderr,文件末尾需要有一个换行符:
error: proc-macro derive panicked
--> tests/fails/build_enum.rs:4:10
|
4 | #[derive(Builder)]
| ^^^^^^^
|
= help: message: not implemented: Only implemented for structs
这是我们期望在将宏应用于枚举时得到的结果:之前在本章中编写的 panic!所以它按预期工作——好吧,失败——如我们所预期。我们本可以添加一个函数示例。但 derive 宏只能应用于结构体、枚举和联合体,因此 Rust 会为我们处理这个问题。这意味着我们已经涵盖了宏的最重要的不愉快路径。如果你愿意,你现在可以将之前放在 main.rs 中的愉快路径测试移到 tests 文件夹中。这样,你就可以将所有测试集中在一个地方。
6.5 我需要什么样的单元测试?
理想情况下,所有单元测试!但它们的重要性确实有所不同。
一个简单的愉快路径编译测试(黑盒测试)似乎是最基本的要求。如果你的宏在你预期的情况下都无法编译,那么它有什么用呢?如果你有很多失败模式需要测试,那么不愉快路径的编译失败可能很重要。但对于一个简单的宏来说,可能会很少出现这种情况,或者你可能会找到方法绕过它们。
关于白盒单元测试——嗯,这取决于情况。我非常喜欢 Rust 允许你测试私有函数。这让你可以隐藏所有信息,同时也能轻松地测试纯函数。这与我个人的偏好相吻合:保持不纯函数的大小和数量尽可能小,把转换和业务逻辑尽可能移到纯函数中。在不纯的部分,我们与外部系统、数据库等进行通信时,可以依赖类型系统来引导我们朝正确的方向前进,并避免做出愚蠢的事情。(如果你有任何疑虑,可以添加一些集成测试。)而且,由于纯函数很容易测试,我依然可以在不考虑诸如模拟或如何引入真实依赖项等问题的情况下,获得我代码库的大部分良好覆盖。
注:纯函数是指没有副作用,且其返回值完全由输入参数决定的函数。我们将在下一章再次讨论它们。
话虽如此,宏的白盒测试工作量很大。创建像本章中的帮助函数的输入和输出非常繁琐。(我不讨论那些没有宏特定输入和输出的函数,对于这类函数,参见前面一段的简短讨论。)在一个大型项目中,这样的测试可以帮助你及早发现 bug。但黑盒测试也能相对较快地发现问题,设置和推理更容易,并且不容易受到细微变化的影响。即使只是移动输出的一部分,也会破坏白盒测试,而不会改变我们代码的工作方式和含义。另一方面,黑盒测试中的错误信息可能不够清晰,需要你跟踪一长串的线索(希望你在每次增加小段代码后都在运行测试!)。因此,在更大、更复杂的应用中,内部测试(在战略性的位置)是非常值得的。
6.6 超越单元测试
我们花了一整章的时间编写单元测试,但这只是测试领域的冰山一角。例如,你还可以进行一些从更广泛角度进行的测试:
- 集成测试验证系统各部分的集成是否按预期工作。它们通常比单元测试慢,因为它们涵盖更多的代码,并且可能涉及真实的依赖,但它们能够覆盖更多领域,并提供更大的信心,确保你的代码能按预期工作。
- 端到端测试验证整个系统的工作原理,这意味着大部分或甚至所有的依赖项都会是实际的。这样的测试套件在持续部署中非常有价值,可以确保你的代码可能没有破坏任何东西——如果你能够保持它正常工作,因为端到端测试可能会变得脆弱且难以维护。
- 冒烟测试是端到端测试的一种变体。它们关注有限数量的重要路径,并确保你部署的内容没有完全坏掉。(服务器上冒烟了吗?)如果你的应用程序在什么都不做的情况下不断崩溃,这些测试会告诉你。
其他类型的测试则采取不同的方法或重点:
- 性能测试验证系统在各种负载下的速度和可靠性特性。对于 Rust,你可以使用 Criterion.rs(github.com/bheisler/cr…)。
- 负载测试类似,但其主要目标是验证你的应用在高负载下是否仍能正常工作,而不会降级。
- 契约测试检查生产者是否按约定格式返回数据(即是否遵守现有的公共 API)。这可以避免由于生产者数据格式变化而意外破坏消费者的功能。
- 变异测试是一种相对较新的类别,它对你的代码进行小的修改(变异),并检查是否有单元测试因变化而失败。如果修改实现没有导致任何测试失败,可能意味着你缺少测试覆盖。
- 模糊测试通过注入各种无效或意外的数据,试图使应用崩溃。修复模糊测试发现的错误会使你的代码更加安全和稳定。对于 Rust,你可以通过将 libFuzzer(llvm.org/docs/LibFuz…)与cargo fuzz(github.com/rust-fuzz/c…)结合来进行模糊测试。
- 基于属性的测试在函数式编程世界中很受欢迎,适用于测试那些有特定数学保证的代码契约。它会生成一系列(成百上千)值,并使用这些值验证你的代码始终遵守其保证。例如,对于一个求和函数,它会生成两个随机值(假设是 x 和 y),并检查返回值是否等于 x + y。
还有一些专门针对 Rust 的测试变体:
- 文档测试(Doctests) 是嵌入在文档中的 Rust 单元测试。
- Miri 解释器可用于检查代码中的一些未定义行为,使你的应用更加安全。
- Loom是一个工具,用于通过探索“所有可能的有效行为”来验证并发程序的正确性。
显然,我们无法深入探讨所有这些测试类型,那样本书的篇幅将翻倍!但是,在编写宏时,哪些测试是有用的呢?在我看来,集成测试检查你与其他系统(如 API 或数据库)之间的交互,可以根据你创建的宏类型来决定是否有用。对于我们当前的例子(构建器宏),单元测试已经足够了。但如果你编写的是与数据库交互的宏,你肯定会希望进行超越单元测试的测试——这种测试实际上是在本地运行的真实数据库,或者理想情况下是在服务器或云中的数据库上进行。因此,在另一章中,我们将回到集成测试的话题。那时,我们还将讨论文档,因此文档测试也会作为内容出现。
6.7 来自真实世界的例子
几乎没有宏库完全没有测试。大多数宏库都会有几个单元测试,并且常常使用 trybuild 来处理失败场景(例如,Tokio 和 lazy_static)。在这里,我们将讨论两个值得检查的库。
第一个是 Rocket,我们在介绍部分和第 3 章中都有提到。它是一个 Rust 的 Web 框架,使用宏来生成 HTTP 端点。例如,给函数添加宏 #[get("/hello/<name>")] 会使其成为一个指向路径 /hello 的 URL 的入口,并且会传递一个名为 “name” 的路径参数。Rocket 的单元测试是黑盒测试,位于项目的 tests 文件夹中。它包括大量的正向路径单元测试以及使用 trybuild 进行的多个失败场景测试。除此之外,Rocket 的独立包中还包括使用 Criterion 进行的“基准测试”和模糊测试设置。
下一个库是 serde(serde.rs/),它是一个非常流行的工具,用于序列化和反序列化数据(例如,将原始JSON 转换为自定义结构体)。你可以通过使用它的 derive 宏来告诉 serde 执行此操作,一个用于序列化,另一个用于反序列化。一旦添加了这些宏,事情就会“自动工作”。serde 拥有各种各样的测试。有些是白盒单元测试,用于测试简单的纯函数(以下示例已被简化):
#[test]
fn rename_fields() {
for &(original, upper, pascal) in &[ ("outcome", "OUTCOME", "Outcome"), ("very_tasty", "VERY_TASTY", "VeryTasty"), ] {
assert_eq!(None.apply_to_field(original), original);
assert_eq!(UpperCase.apply_to_field(original), upper);
assert_eq!(PascalCase.apply_to_field(original), pascal);
}
}
但这些属于例外。大多数测试是黑盒单元测试,混合了正向路径和失败路径,使用 trybuild 进行测试。
你可能会好奇这些测试是针对什么实现运行的。毕竟,serde 帮助你将 Rust 代码转换成像 JSON 这样的具体格式。所以你需要某种实现来测试 serde 的特征,以便进行输出比较。但测试实际实现存在风险。这也会导致循环依赖,因为这个实现本身就依赖于 serde。相反,serde_test 提供了一个简单的实现,可以用于验证行为。