介绍 Test That

Lobsters Hottest 工具

摘要

Test That 是一个新的 Rust 测试断言库,源自 GoogleTest Rust,提供更简洁的 API、可选的依赖项以及更好的匹配器组合性。

<p><a href="https://lobste.rs/s/al5bqb/introducing_test">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/06/29 00:21

# 博客 -- Bradford Hovinen 来源:https://hovinen.me/announcements/2026/06/24/introducing-test-that.html 我很兴奋地宣布发布 `Test That!` (https://crates.io/crates/test-that) —— 一个强大的 Rust 测试断言库。它是 GoogleTest Rust (https://crates.io/crates/googletest) 的一个分支。 ## TL;DR `Test That!` 允许你编写能够精确表达你*意图*的测试断言: ``` let vec = vec![5, 123, -4]; verify_that!(vec, each(gt(0))) ``` 并在测试失败时提供信息丰富且有意义的诊断信息: ``` Value of: vec Expected: only contains elements that is greater than 0 Actual: [5, 123, -4], whose element #2 is -4, which is less than or equal to 0 ``` ## `Test That!` 有哪些新特性? 与 GoogleTest 相比,`Test That!` 提供了一些改进: - 相比最近版本的 GoogleTest,它的 API 更简洁、更简单、更易用。下面 (https://hovinen.me/announcements/2026/06/24/introducing-test-that.html#why-did-i-fork-googletest) 我会详细解释。 - 相比旧版本的 GoogleTest,它消除了匹配器在组合性方面的各种限制;请参见下文 (https://hovinen.me/announcements/2026/06/24/introducing-test-that.html#solving-the-limitations-of-googletest-011)。 - `Test That!` 的所有依赖都是可选的。因此,关闭所有特性后,crate 没有任何依赖。这意味着依赖特定功能(包括非致命断言、正则表达式和浮点数匹配器)将不可用。不过,默认特性集包含了所有这些功能。 - 我将 `verify_that!` (https://docs.rs/test-that/latest/test_that/macro.verify_that.html) 及其相关宏中用于匹配容器的简写语法扩展到了其他基于宏的匹配器,例如 `matches_pattern!` (https://docs.rs/test-that/latest/test_that/matchers/macro.matches_pattern.html)。因此,你可以写出像这样的代码: ``` matches_pattern!(MyStruct { a_vec: [eq(1), eq(2), eq(3)] }) ``` - 类型别名 `Result` 现在更名为 `TestResult` (https://docs.rs/test-that/latest/test_that/type.TestResult.html),这样就不会与你可能想要使用的其他 `Result` 类型冲突。 - 我将 `unordered_elements_are!` 重命名为 `contains_exactly!` (https://docs.rs/test-that/latest/test_that/matchers/containers/macro.contains_exactly.html),并为该匹配器添加了 `in_order()` (https://docs.rs/test-that/latest/test_that/matchers/containers/struct.ContainerContainsUnorderedMatcher.html#method.in_order) 方法。因此,现在使用 `contains_exactly![...].in_order()` 代替 `elements_are!`。原有的结构一直让我困扰:它是从 GoogleTest C++ 库 (https://github.com/google/googletest) 继承而来的,并在其基础上不断演变。我想要一个干净的重构,而现在的结构感觉更自然:更强的约束需要比弱约束更复杂的语法。 - “子集”匹配器 `contains_each!` (https://docs.rs/test-that/latest/test_that/matchers/containers/macro.contains_each.html) 和“超集”匹配器 `is_contained_in!` (https://docs.rs/test-that/latest/test_that/matchers/containers/macro.is_contained_in.html) 现在支持通过 `in_order()` (https://docs.rs/test-that/latest/test_that/matchers/containers/struct.ContainerContainsUnorderedMatcher.html#method.in_order) 方法强制元素与其对应匹配器的顺序一致。 - 在 `contains_exactly!` 中匹配 `HashMap` 的语法现在使用箭头运算符 `=>` 表示键值对: ``` let value = HashMap::from([(1, "one"), (2, "two"), (3, "three")]); verify_that!(value, contains_exactly![eq(1) => eq("one"), eq(2) => eq("two"), eq(3) => eq("three")]) ``` 以前,它们是用元组表示的,这意味着你无法使用该匹配器来匹配 `Vec` 的键值对。 - 我将 `Matcher` (https://docs.rs/test-that/latest/test_that/matcher/trait.Matcher.html) trait 进行了拆分,将 `describe()` (https://docs.rs/test-that/latest/test_that/matcher/trait.Describable.html#tymethod.describe) 方法移到了一个名为 `Describable` (https://docs.rs/test-that/latest/test_that/matcher/trait.Describable.html) 的新 trait 中。这允许在某些情况下减少代码重复。 对于任何有兴趣从 GoogleTest 迁移到 `Test That!` 的人:别担心!我已经包含了一些特性 (https://github.com/hovinen/test-that#porting-from-googletest-rust),添加了别名以便更容易地移植现有代码。 ## 为什么我要 fork GoogleTest? 几年前我在谷歌工作时,牵头开发了 GoogleTest crate。目标是将 GoogleTest C++ 库的强大断言功能引入 Rust。我一直参与 GoogleTest 直到 2023 年离职后不久。 0.12 版本引入了一个关键性的变更 (https://github.com/google/googletest-rust/pull/367),该变更极大地影响了库的设计假设。我认为这一变更显著恶化了开发者体验。为了说明这一点,我们来看几个例子。 让我们从一个简单的数据模型开始: ``` #[derive(Debug)] struct AStruct { value: u32, string: String, } ``` 假设我有一个该结构体的值: ``` let value = AStruct { value: 123, string: "Hello, world!".into(), }; ``` 现在假设我想要断言该结构体中的数据符合我的预期。在 GoogleTest 0.11 中,代码会是这样: ``` verify_that!( value, matches_pattern!(AStruct { value: eq(123), string: eq("Hello, world!"), }) ) ``` 如果我尝试在 GoogleTest 0.12 及更高版本中做同样的事情,会得到一些错误: ``` error[E0277]: can't compare `&u32` with `{integer}` --> src/main.rs:25:13 | 23 | / verify_that!( 24 | | value, 25 | |/ matches_pattern!(AStruct { 26 | || value: eq(123), 27 | || string: eq("Hello, world!"), 28 | || }) | ||______________^ no implementation for `&u32 == {integer}` 29 | | ) | |__________- required by a bound introduced by this call ``` 等等,什么?它说无法将某物与引用进行比较。但我的代码中并没有引用啊。好吧,那我在数字前加一个引用: ``` verify_that!( value, matches_pattern!(AStruct { value: eq(&123), string: eq("Hello, world!"), }) ) ``` 好吧,看来现在在 GoogleTest 中需要在数字前加引用。那我们试试在其他地方应用这个知识: ``` let value = 123; verify_that!(value, eq(&123)) ``` 编译时,我们得到: ``` error[E0277]: can't compare `{integer}` with `&{integer}` --> src/main.rs:36:29 | 36 | verify_that!(value, eq(&123)) | --------------------^^^^^^^^- | | | | | no implementation for `{integer} == &{integer}` | required by a bound introduced by this call | = help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}` = help: the following other types implement trait `PartialEq`: f128 f16 f32 f64 i128 i16 i32 i64 and 8 others = note: required for `EqMatcher<&{integer}>` to implement `googletest::matcher::Matcher<{integer}>` ``` 好吧,那去掉引用看看会发生什么: ``` let value = 123; verify_that!(value, eq(123)) ``` 编译通过了。原来,在*那个*上下文中,*不能*加引用,而在*前一个*上下文中,*必须*加引用。这变得越来越令人困惑。 让我们再试试其他的。假设我有一个如下的 newtype: ``` #[derive(Debug)] struct NewType(u32); ``` 我可能以前是这样匹配它的: ``` let value = NewType(123); verify_that!(value, matches_pattern!(NewType(eq(123)))) ``` 现在,似乎我必须这样做: ``` let value = NewType(123); verify_that!(value, matches_pattern!(NewType(eq(&123)))) ``` 这编译并运行良好。但假设我现在修改 `NewType`,让它实现 `Copy`: ``` #[derive(Debug, Clone, Copy)] struct NewType(u32); ``` 突然间,我的测试无法编译了! ``` error[E0277]: can't compare `u32` with `&{integer}` --> src/main.rs:46:29 | 46 | verify_that!(value, matches_pattern!(NewType(eq(&123)))) | --------------------^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^- | | | | | no implementation for `u32 == &{integer}` | required by a bound introduced by this call | help: the trait `PartialEq<&{integer}>` is not implemented for `u32` but trait `PartialEq` is implemented for it --> /home/hovinen/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/cmp.rs:1875:13 | 1875 | impl const PartialEq for $t { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ ... 1897 | / partial_eq_impl! { 1898 | | bool char usize u8 u16 u32 u64 u128 isize i8 i16 i32 i64 i128 f16 f32 f64 f128 1899 | | } | |_____- in this macro invocation = help: for that trait implementation, expected `u32`, found `&{integer}` = note: required for `EqMatcher<&{integer}>` to implement `googletest::matcher::Matcher` = note: 3 redundant requirements hidden = note: required for `CompileAssertAndMatch>` to implement `googletest::matcher::Matcher` ``` 这真的很奇怪。为一个现有结构体添加 `Copy` 本应该是兼容的变更——毕竟,我们只是在*增加*类型的能力(以及约束)。类型的下游用户不应该因此而出错。 这些只是几个简单的例子。尝试得越多,就越令人困惑。(别提 `ref` 关键字了!)如果将其放大到一个拥有庞大而复杂的数据结构和复杂断言的大型代码库,很快就会变成一场噩梦。 GoogleTest 背后的一个核心理念是:编写单元测试应该毫不费力。你不应该花费太多脑力去编写有用的断言。你不应该去思考:“我怎么才能让测试只断言这一个属性,*并且*确保断言失败消息有意义?”只需让匹配器来处理即可。 我相信如果我坐下来花足够长的时间思考,我一定能找到正确的思维模型来驾驭这一切。但那需要*大量的*脑力投入,而必须有*非常充分的*理由才值得投入。特别是,我不仅需要自己投入,还需要说服我的同事和合作者也这样做。 为什么会引入这个变更?事实上,GoogleTest 现有设计确实存在一些局限性。它在 99% 的情况下都能正常工作,但对于某些边界情况,它表现得相当糟糕。下面,我将展示其中一些限制,以及我是如何在不付出如此高昂代价的情况下消除它们的。 ## 解决 GoogleTest 0.11 的局限性 我决定不移植自己的项目,而是继续使用 GoogleTest 0.11。然而,随着时间的推移,GoogleTest 0.11 的各种缺陷和局限性变得难以忽视。而且,鉴于升级对我来说不是一个选项,我实际上是在使用一个被遗弃的库,没有任何机会让上游修复这些问题。 我指的是哪些问题呢?首先,在匹配器的使用上存在一些烦人的限制。以之前的例子中的 `matches_pattern!` 宏为例。你可以用它来匹配方法的返回值: ``` impl AStruct { fn get_value(&self) -> u32 { self.value } } verify_that!(value, matches_pattern!(AStruct { get_value(): eq(123), })) ``` 但是当返回值是切片或字符串切片时,这就不行了: ``` impl AStruct { fn get_string(&self) -> &str { &self.string } } verify_that!(value, matches_pattern!(AStruct { get_string(): eq("Hello, world!"), // 编译器错误! })) ``` 我发现它无法匹配返回 `Option<&SomeType>` 的方法,这阻碍了对 anyhow (https://crates.rs/crates/anyhow) 错误来源的断言。它还会绊倒 (https://github.com/google/googletest-rust/issues/323) 那些缩小内部持有引用生命周期的方法。并且它不支持 (https://github.com/google/googletest-rust/issues/351) 产生所有权值而非借用值的容器。 随着时间的推移,这些限制开始累积,导致了一些真正的头疼问题。于是我开始研究如何在不改变库基本结构的情况下解决这些限制。 ### 两个设计缺陷 GoogleTest 0.11 中存在两个相互影响的设计缺陷。通过修复它们,我能够解决上述所有限制。 首先,我最初希望尽可能减小 API 表面。这意味着大多数匹配器函数将返回不透明的 `Matcher` 实现,而具体类型将保持私有。一个(简化的)匹配器大致如下: ``` pub fn eq(value: T) -> impl Matcher<ActualT = T> { EqMatcher { value } } struct EqMatcher<T> { value: T, } ``` `Matcher` trait 本身需要知道它可以匹配哪些类型。有两种方式实现:作为 `Matcher` 的类型参数,或者作为该 trait 的关联类型。最初我选择了类型参数,但在遇到 (https://hovinen.me/blog/2023/06/06/demystifying-trait-generics-in-rust/) 一些类型解析问题后,我选择了关联类型。 ``` impl<T: PartialEq> Matcher for EqMatcher<T> { type ActualT = ???; fn matches(&self, actual: &Self::ActualT) -> MatcherResult { ... } } ``` 当然,你可以直接放 `T` 在那里,但这会让匹配器过于僵化。例如,比较一个字符串切片和一个拥有的 `String` 是完全合法的: ``` let slice = "Hello, world"; let string = String::from("Hello, world"); assert!(slice == string); ``` 所以实际类型和期望类型不必相同。它们只需要是可比较的,这通过 `PartialEq` (https://doc.rust-lang.org/std/cmp/trait.PartialEq.html) trait 来表达。为了支持这一点,你需要*另一个*类型参数来表示正在匹配的类型。 ``` impl<ExpectedT: PartialEq<ActualT>, ActualT> Matcher for EqMatcher<ExpectedT> { type ActualT = ActualT; ... } ``` 但这不会编译,因为 impl 块中的类型 `ActualT` 是*不受约束的*。它适用于*任何*可以与期望值类型进行比较的 `ActualT`。但对于固定的 `ExpectedT`,只能有一个实现。所以编译器不知道选哪一个。 为了解决这个问题,我必须让 `ActualT` 成为结构体和匹配器函数的类型参数。 ``` pub fn eq<ExpectedT, ActualT>(value: ExpectedT) -> impl Matcher<ActualT = ActualT> { EqMatcher::<ExpectedT, ActualT> { value, _phantom: PhantomData } } struct EqMatcher<ExpectedT, ActualT> { value: ExpectedT, _phantom: PhantomData<ActualT>, } ``` 问题就在这里:*这意味着 `ActualT` 的类型在 `eq` 的调用点就被固定了*。现在假设 `ActualT` 是一个带有生命周期的引用。这个生命周期是类型的一部分。所以*生命周期*现在也被固定了。但有些匹配器是通过闭包提取实际值的。例如,`matches_pattern!` (https://docs.rs/test-that/latest/test_that/matchers/macro.matches_pattern.html) 中的属性值就是这样获取的。在这种情况下,调用点无法知道生命周期。代码在概念上类似于: ``` let matcher = eq(expected); let closure = |s: &MyStruct| s.get_value(); matcher.matches(closure(actual)); ``` 如果 `MyStruct::get_value()` 返回一个拥有 `'static` 生命周期的值,那没问题。但如果它返回一个绑定到闭包参数生命周期的值,那么 `matcher` 的类型就过于僵化了。它匹配的类型必须满足*每个生命周期*的约束,而不是*固定的*生命周期。 `Test That!` 通过三个关键更改解决了这个问题: - 首先,`Matcher` trait 现在再次将实际值作为类型参数。这使得它可以自由地为多种类型实例化。特别是,实际类型的生命周期不再需要固定。 - 其次,各种匹配器及其创建函数不再由其匹配的类型参数化。这些类型改为由其*使用*位置决定。 - 最后,为了让这一切正常工作,我放弃了将匹配器结构体排除在公共 API 表面之外。匹配器函数现在直接返回具体结构体,而不是不透明的 `Matcher` 实现。 结果大致是这样的:

相似文章

令人愉悦的 Rust 集成测试

Lobsters Hottest

一篇博客文章,演示如何使用 RAII 和 testcontainers-rs 库,通过运行时管理应用程序基础设施,在 Rust 中编写令人愉悦的集成测试。

模拟 Rust 代码的所有方法

Lobsters Hottest

一篇教程,涵盖了在 Rust 中模拟网络调用的多种策略,以发出事件的 Kubernetes 控制器为例,重点强调不降低生产代码的可测试性。