53 个版本
0.6.7 | 2024 年 7 月 27 日 |
---|---|
0.6.5 | 2024 年 4 月 8 日 |
0.6.3 | 2024 年 3 月 28 日 |
0.5.7 | 2023 年 11 月 16 日 |
0.1.0 | 2022 年 3 月 13 日 |
#49 in 测试
928 每月下载量
用于 entrait
210KB
4K SLoC
unimock
unimock
是一个用于定义特质 模拟实现 的库。
模拟,广义上讲,是在测试执行期间控制 API 行为的一种方式。
unimock 中的 uni 表示单一性:所有可模拟的特质都由单个类型 Unimock
实现。这种设计允许在编码风格上具有很大的灵活性,如下文将进一步演示。
第一个代码示例是使用 unimock 的最小可能方式
use unimock::*;
#[unimock]
trait Foo {}
fn takes_foo(foo: impl Foo) {}
takes_foo(Unimock::new(()));
trait Foo
使用#[unimock]
属性声明,这使得其行为可模拟。fn takes_foo
接受实现该特质的一些类型。此函数遵循零成本的 控制反转/依赖反转。- 通过调用
Unimock::new(())
来创建模拟实例,这将创建一个Unimock
值,并将其传递给takes_foo
。
new
函数接受一个名为 setup
的参数(实现 Clause
),在这种情况下是单位值 ()
。设置参数是 被模拟的行为,在这种情况下什么都没有。由于 Foo
没有方法,因此没有要模拟的行为。
方法和行为模拟
为了使其具有一定的实用性,我们抽象的特质应该包含一些方法。在某个函数的单元测试中,我们希望模拟该函数依赖项的行为(以特质界限的形式表示)。
给定某个特质
#[unimock]
trait Foo {
fn foo(&self) -> i32;
}
我们希望告诉 unimock Foo::foo
的行为将是什么,即它将返回什么。为了做到这一点,我们首先需要引用该方法。在 Rust 中,特质方法不是具体化的实体,它们既不是类型也不是值,因此不能在代码中引用。我们需要告诉 unimock 暴露一个单独的模拟 API。这个 API 将以新模块的形式创建,通过传递例如 api=TraitMock
到 unimock 宏调用来命名。
特质的所有原始方法将通过此模块导出为模拟配置入口点:例如 TraitMock::method
。 method
是一个将实现 MockFn
的类型,这是创建 Clause
的入口点。
#[unimock(api=FooMock)]
trait Foo {
fn foo(&self) -> i32;
}
fn test_me(foo: impl Foo) -> i32 {
foo.foo()
}
let clause = FooMock::foo.each_call(matching!()).returns(1337);
assert_eq!(1337, test_me(Unimock::new(clause)));
子句构造是一个类型状态机,在这个示例中,它经过两个步骤
FooMock::foo.each_call(matching!())
:定义一个 调用模式。每个匹配空参数列表(即始终匹配,因为方法是参数化的)的Foo::foo
调用。.returns(1337)
:每个匹配的调用将返回值1337
。
在这个示例中只有一个子句。
调用模式(匹配输入)
通常希望控制函数根据给定的输入如何响应!通过一个接收输入作为元组并返回是否匹配的布尔值的函数来匹配输入。从现在起,一个特定的 MockFn
与一个输入匹配器一起称为 调用模式。
matching!
宏提供了参数匹配的语法糖。它的语法灵感来源于 std::matches
宏。
正在匹配的输入是一个条件,必须满足该条件,才能评估调用模式的其余部分。
指定输出(响应)
可以通过多种方式指定输出。最简单的一种是 returns(some_value)
。不同的指定输出方式可以在 build::DefineResponse
中找到。
根据子句的初始化方式,不同的约束作用于返回值
some_call
专为仅发生一次的调用定制。返回值没有 [Clone] 约束。each_call
专为预期发生多次的调用定制,因此需要在返回值上要求 [Clone]。next_call
用于验证确切的调用序列,否则与some_call
的工作方式相似。
修改输入
许多特性使用参数修改模式,其中有一个或多个&mut
参数。
要访问&mut
参数(并修改它们),需要使用answers
对调用模式应用一个函数
let mocked = Unimock::new(
mock::core::fmt::DisplayMock::fmt
.next_call(matching!(_))
.answers(&|_, f| write!(f, "mutation!"))
);
assert_eq!("mutation!", format!("{mocked}"));
answers
的参数是一个具有与它模拟的方法相同签名的函数,包括self
参数。
组合设置子句
Unimock::new()
接受任何实现[子句]的类型作为参数。基本设置子句可以通过使用元组组合成复合子句。
#[unimock(api=FooMock)]
trait Foo {
fn foo(&self, arg: i32) -> i32;
}
#[unimock(api=BarMock)]
trait Bar {
fn bar(&self, arg: i32) -> i32;
}
fn test_me(deps: &(impl Foo + Bar), arg: i32) -> i32 {
deps.bar(deps.foo(arg))
}
assert_eq!(
42,
test_me(
&Unimock::new((
FooMock::foo
.some_call(matching!(_))
.answers(&|_, arg| arg * 3),
BarMock::bar
.some_call(matching!((arg) if *arg > 20))
.answers(&|_, arg| arg * 2),
)),
7
)
);
// alternatively, define _stubs_ for each method.
// This is a nice way to group methods by introducing a closure scope:
assert_eq!(
42,
test_me(
&Unimock::new((
FooMock::foo.stub(|each| {
each.call(matching!(1337)).returns(1024);
each.call(matching!(_)).answers(&|_, arg| arg * 3);
}),
BarMock::bar.stub(|each| {
each.call(matching!((arg) if *arg > 20)).answers(&|_, arg| arg * 2);
}),
)),
7
)
);
在这两个例子中,子句指定的顺序并不重要,除了输入匹配。为了使unimock找到正确的响应,调用模式将按照它们定义的顺序进行匹配。
交互验证
Unimock使用声明式方法执行交互验证。预期的交互在构建时配置,使用[子句]。Rust通过RAII和Unimock实现的[drop]方法使自动验证成为可能。当Unimock实例超出作用域时,Rust会自动运行其验证规则。
Unimock中始终启用一个验证
在某个设置子句中提到的每个MockFn
都必须至少交互一次。
如果不符合此要求,Unimock将在其Drop实现中panic。这样做的原因是为了帮助避免测试代码中随着时间的推移积累“位退化”。在重构发布代码时,测试应该始终跟随,不要过于通用。
一般来说,子句不仅编码了允许发生的行为,而且也编码了这种行为必须发生。
调用模式中的可选调用计数期望
要为特定的调用模式设置调用计数期望,请查看Quantify
或QuantifyReturnValue
,它们有诸如once()
、n_times(n)
和at_least_times(n)
等方法。
在放置了精确量化后,可以通过链式组合器构建输出序列验证。
each.call(matching!(_)).returns(1).n_times(2).then().returns(2);
输出序列将是[1, 1, 2, 2, 2, ..]
。这种调用模式必须至少匹配3次。其中两次是因为第一个精确输出序列,然后至少一次是因为.then()
组合器。
验证确切的调用序列
可以使用严格排序的子句来表示确切的调用序列。使用next_call
来定义这种调用模式。
Unimock::new((
FooMock::foo.next_call(matching!(3)).returns(5),
BarMock::bar.next_call(matching!(8)).returns(7).n_times(2),
));
所有由next_call
构建的子句都期望按照它们在子句元组中出现的顺序进行评估。
具有顺序敏感的子句和顺序不敏感的子句(如some_call
)之间不会相互干扰。然而,这些类型的子句不能在一个Unimock值中组合使用,用于同一个MockFn。
应用程序架构
使用unimock编写大型、可测试的应用程序需要一定程度的架构纪律。我们已知如何使用特例边界来指定依赖项。但是,当涉及多个层次时,这在实际中能否扩展?unimock的一个主要特性是所有特例都由Unimock
实现。这意味着特例边界可以组合,我们可以使用一个实现所有依赖项的值。
fn some_function(deps: &(impl A + B + C), arg: i32) {
// ..
}
从某种意义上讲,这个函数类似于一个接收self
的函数。`deps`参数是函数如何抽象其依赖项的方式。让我们保持这种调用约定,并通过引入两层使其稍微扩展一下。
use std::any::Any;
trait A {
fn a(&self, arg: i32) -> i32;
}
trait B {
fn b(&self, arg: i32) -> i32;
}
fn a(deps: &impl B, arg: i32) -> i32 {
deps.b(arg) + 1
}
fn b(deps: &impl Any, arg: i32) -> i32 {
arg + 1
}
从fn a
到fn b
的依赖关系完全被抽象掉了,在测试模式下,`deps:&impl X`被替换为`deps:&Unimock`。但是Unimock只关注画面中的测试方面。前面的代码片段是松耦合规模的极限:完全没有耦合!它表明unimock只是更大画面中的一个部分。要将所有这些组合成一个完整的运行时解决方案,而不需要太多的样板代码,请尝试使用entrait模式。
门控模拟实现
如果特例定义、特例边界的使用和测试都位于同一个crate中,可以门控宏调用。
#[cfg_attr(test, unimock(api = FooMock))]
trait Foo {}
将发布代码和模拟结合:部分模拟
Unimock可以用来创建任意深度的集成测试,模拟只间接使用的层。为了使其正常工作,unimock需要知道如何调用特例的“真实”实现。
查看new_partial
的文档以了解它是如何工作的。
尽管这可以直接使用unimock实现,但它与更高级的宏(如entrait
)配合使用效果最佳。
no_std
Unimock可以在no_std
环境中使用。默认情况下启用`std`特性,可以通过删除它来启用no_std
。
no_std
环境依赖于alloc并需要一个全局分配器。一些unimock特性依赖于Mutex的可用实现,而`spin-lock`特性为no_std
启用了这一点。`critical-section`特性也用于no_std
。这两个特性可能会在未来某个重大版本更新中合并。
中心crate的模拟API
当被抽象的特例定义在包含测试的代码库中时,unimock运行良好。Rust孤儿规则确保unimock用户不能为其项目上游的特例定义模拟实现。
因此,Unimock已经开始向一个方向移动,即它自己定义中心crates的mock API。
这些mock API可以在[mock]中找到。
杂项
可以使用unimock模拟哪些类型的事物?
- 具有任何数量方法的特质
- 具有泛型参数的特质,尽管这些不能受到生命周期约束(即需要满足
T: 'static'
)。 - 具有关联类型和常量的特质,使用
#[unimock(type T = Foo; const FOO: T = value;)]
语法。 - 任何self接收者的方法(
self
、&self
、&mut self
或任意(例如self: Rc<Self>
))。 - 接受引用输入的方法。
- 返回从self借用值的的方法。
- 返回指向参数引用的方法。
- 返回包含从self借用值的
Option<&T>
、Result<&T, E>
或Vec<&T>
的方法,其中T
是从self
借用的。 - 返回最多4个元素的self借用或拥有的任意元组的组合的方法。
- 返回包含生命周期参数的类型的方法。对于mocked返回,它们必须是
'static'
。 - 使用显式泛型参数或参数位置
impl Trait
的泛型方法。 - 是
async
或返回impl Future
的方法。 async_trait
注释的特质。
哪些类型或方法不能被模拟?
- 静态方法,即没有
self
接收者。尽管接受具有默认体的静态方法,但不能模拟。
为mock API选择名称
由于宏卫生,unimock试图避免自动生成任何可能意外创建不想要的命名空间冲突的新标识符。为了避免通过凭空创造新标识符名称而产生用户混淆,因此mock API的名称必须由用户提供。尽管用户可以自由选择任何名称,但unimock建议遵循命名约定。
被模拟的实体是一个特质,但mocking API是一个模块。这引入了命名约定风格的冲突,因为特质使用驼峰命名法,而模块使用蛇形命名法。
建议的命名约定是使用特质的名称(例如Trait
)后缀为Mock
:生成的模块应称为TraitMock
。
这将使发现API更容易,因为它与特质的名称共享公共前缀。
具有默认实现的方法
使用默认实现的函数默认使用默认委派。这意味着,如果一个默认实现方法在没有在条款中提及的情况下被调用,Unimock将委派到其默认实现而不是引发恐慌。通常,一个典型的默认实现会自身委派回一个必需的方法。
这意味着你可以控制你想模拟的特例API的哪个部分,是高级部分还是低级部分。
关联类型
在特例中,可以使用unimock宏中的type
关键字来指定关联类型。
#[unimock(api = TraitMock, type A = i32; type B = String;)]
trait Trait {
type A;
type B;
}
在Unimock等模拟环境中处理关联类型有其局限性。关联类型的本质是每个实现都有一个类型,只有一个模拟实现,因此必须仔细选择类型。
关联常量
在特例中,可以使用unimock宏中的const
关键字来指定关联常量。
#[unimock(api = TraitMock, const FOO: i32 = 42;)]
trait Trait {
const FOO: i32;
}
与Unimock中的关联类型一样,关联常量也有局限性,每个实现只有一个常量值,只有一个模拟实现,因此必须仔细选择值。
项目目标
仅使用安全的Rust
Unimock尊重Rust提供的内存安全和健壮性。有时这个事实可能会导致用户体验不如最佳。
例如,为了使用.returns(value)
,值通常必须实现Clone
、Send
、Sync
和'static
。如果不是所有这些,可以使用稍微长一点的.answers(&|_| value)
代替。
将生成的代码量保持在最低限度
unimock API主要围绕泛型和特例构建,而不是使用宏生成。任何模拟库都可能需要一定程度的自省元编程(如宏),但过多地这样做可能会让用户感到更困惑,并且编译时间更长。#[unimock]
宏只做了最基本的事情来填充一些简单的特例实现,仅此而已。不需要生成复杂的函数或结构。
这种方法的缺点是,虽然Rust泛型不是无限灵活的,所以有时可能会以类型系统无法提前捕获的方式错误配置模拟,从而导致运行时(或更确切地说,测试时)失败。
综合考虑,这种权衡似乎是合理的,因为毕竟这只是测试。
使用简洁易读的API
Unimock的模拟API被设计得就像自然英语句子一样。
这是一个有趣的设计挑战,但可以说也有一定的实际价值。假设代码在类似于真实语言时,阅读和编写速度更快(也许更有趣)。
依赖关系
~0.5–2.3MB
~42K SLoC