Chigozie Oduah 2023-05-16
测试是软件工程中不可或缺的一部分。对于初学者来说,编写测试用例可以确保你的代码行为完全符合预期。每种编程语言都有各种框架来帮助你测试代码。
小型个人项目可能可以不加测试就运行,但随着应用程序规模的扩大,你会面临一种风险:每当向生产环境推送新功能后,就会变得疑神疑鬼。
有些团队会使用人工测试人员进行回归测试。理论上这很好,但人工测试人员无法捕捉到运行时出现的所有细节问题。而且,考虑到目前可用的自动化测试工具,使用人工测试既昂贵又低效。
尽管如此,愿意为自己的代码编写测试的工程师比例仍然很小;但如果你观察那些构建高质量软件的顶尖工程团队,你会发现测试已成为他们工作流程中不可或缺的一部分。
在本文中,我们将深入探讨 Rust 中一种重要的测试技术——Mocking(模拟)。Rust 中的 Mocking 指的是用模拟对象(mock objects)替换真实依赖。这些对象会模拟真实依赖的行为。
通过 Mocking,你可以隔离被测单元,控制其依赖的行为,从而使测试更加聚焦和可靠。
在对 Rust 中的 Mocking 进行深入探讨时,我们会先了解 Mocking 与一般单元测试的区别,并演示如何使用 Mockall 库实现 Mocking。最后,我们将评估几种可用于 Rust 的 Mocking 替代库。
什么是单元测试?
既然你已经了解了测试代码的重要性,接下来我们来看看单元测试是如何工作的。一旦你完全理解了单元测试的工作原理,就能明白为什么需要 Mocking。
假设你有一个函数,接收两个数字并返回它们的除法结果:
function divide(a, b) {
return a / b;
}
这个函数非常简单:你提供两个数字,就能得到一个输出。但问题是,这种情况是否总是成立?
- 如果
b是 0 呢?在大多数语言中会产生“除零错误”,因为任何数除以零都是无穷大。 - 如果
a和b是数组呢?你能确保调用你函数的代码只会传入预期的数据类型吗?不幸的是,你不能。
这就是单元测试发挥作用的地方。单元测试会从多个角度测试你的代码,确保它能处理这类异常情况。
当然,单元测试不会自动完成这些工作——你必须自己编写这些测试用例。
例如,为了测试上述除法函数,你可以编写如下测试用例:
- 预期
divide(2, 2)的结果为1 - 预期
divide(1, 0)抛出错误
现在你应该明白为什么很多开发者不喜欢写测试用例了:这确实很费工夫。但一旦你习惯了,其带来的好处绝对值得付出这些努力。
单元测试中的 Mocking 是什么?
Mocking 是单元测试中的一种实践。在 Mocking 中,你会为真实对象创建假对象(fake objects),称为 mocks(模拟对象),用于测试目的。这些 mocks 会尽可能地模拟真实对象所需的行为。
你只能在测试中使用模拟对象。Mocking 的目标是将你要测试的代码单元与其依赖项隔离开来。Mocking 能帮助你在隔离环境中测试该单元。
在 Rust 中执行单元测试与其他编程语言有所不同。在许多语言中,你会把所有测试放在一个专门的 test 文件夹中;而在 Rust 中,你通常将测试代码直接写在程序文件中。
例如,假设你为一个项目编写了代码,你会在代码底部的 mod test 模块中编写测试:
fn main() {
task1();
task2();
println!("Accomplished tasks!");
}
fn task1() -> String {
"Accomplished task 1!".to_string()
}
fn task2() -> String {
"Accomplished task 2!".to_string()
}
#[cfg(test)]
mod test {
// 将所有函数导入测试模块
use super::*;
#[test]
fn task1_works() {
assert_eq!(task1(), "Accomplished task 1!".to_string());
}
#[test]
fn task2_works() {
assert_eq!(task2(), "Accomplished task 2!".to_string()); // 注意原文有 typo: task3 → task2
}
}
这是 Rust 中的常见做法。
Mocking 与 Faking 的区别
Faking(伪造) 是指创建对象或服务的简化实现。与 mocks 不同,fakes 是功能性实现。Fakes 的行为类似于真实对象或服务,而 mocks 则是模拟其行为。Fakes 相比真实对象或服务具有更简单、更快的行为。
Mocking 与 Stubbing 的区别
在 Stubbing(桩化) 中,你用一个简化或人工版本的对象替换真实对象。桩对象可以以受控方式模拟真实对象的行为。你可以使用桩对象将测试代码与其依赖项隔离,从而让测试专注于被测的具体功能。
换句话说,stub(桩) 是一种测试替身(test double),它为方法调用提供预定义的响应。和 mock 对象一样,stub 对象允许测试在不依赖依赖项真实实现的情况下运行。但 stub 更简单,适用于较简单的依赖项;而 mocks 更复杂,适用于更复杂的依赖项。
使用 Mockall 创建 Rust 模拟对象
Mockall 是一个提供创建模拟对象工具的库。你可以将模拟对象注入到被测单元中,代替真实依赖。使用模拟对象有助于验证被测单元在不同条件下的行为。
Mockall 提供了自动和手动两种方法从 trait 创建模拟对象。下面我们将分别演示这两种方法。
使用 automock 自动创建模拟对象
要自动创建模拟对象,只需在要模拟的 trait 上使用 #[automock] 属性。请看以下示例:
use mockall::*;
use mockall::predicate::*;
#[automock]
trait MyTrait {
fn foo(&self) -> u32;
fn bar(&self, x: u32) -> u32;
}
let mut mock = MockMyTrait::new();
在此示例中,#[automock] 会为 MyTrait 生成一个模拟结构体。该模拟结构体的名称以 Mock 开头,后接 trait 的名称。最后一行代码从该模拟结构体初始化了一个模拟对象。
自动创建模拟对象是最简单的方法,但对于创建更复杂的对象可能不太适用。在这种情况下,你可能需要使用手动方法。
手动创建模拟对象
要手动创建模拟对象,你需要使用 mock! 宏。请看以下示例:
use mockall::*;
use mockall::predicate::*;
trait MyTrait {
fn foo(&self) -> u32;
fn bar(&self, x: u32) -> u32;
}
mock! {
pub MyStruct {}
impl MyTrait for MyStruct {
fn foo(&self) -> u32;
fn bar(&self, x: u32) -> u32;
}
}
let mut mock = MockMyStruct::new();
在此示例中,mock! 宏创建了一个 MockMyStruct 结构体,它是 MyStruct 的模拟版本。
使用这种方法,你可以为一个模拟结构体实现多个 trait;而自动方法则限制每个模拟结构体只能实现一个 trait。使用手动方法,你可以这样编写模拟结构体:
trait MyTrait1 {
// …
}
trait MyTrait2 {
// …
}
trait MyTrait3 {
// …
}
mock! {
pub MyStruct {}
impl MyTrait1 for MyStruct {
// …
}
impl MyTrait2 for MyStruct {
// …
}
impl MyTrait3 for MyStruct {
// …
}
}
修改模拟对象的行为
仅仅创建模拟对象还不足以模拟依赖的行为。为了测试你的模拟对象,每个方法都需要像真实依赖那样表现。Mockall 允许你设置模拟对象中每个方法的行为。
请看以下示例:
let mut mock = MockMyTrait::new();
mock.expect_foo()
.return_const(44u32);
mock.expect_bar()
.with(predicate::ge(1))
.returning(|x| x + 1);
在这段代码中,我们修改了 mock 中 foo 和 bar 方法的行为:
- 将
foo设置为每次调用都返回44(作为无符号 32 位整数); - 将
bar设置为接受任何大于等于 1 的参数,并返回参数加 1 的结果。
你可以查阅其他谓词函数和期望(expectations),了解还能对模拟对象做哪些修改。你还可以通过 Sequence 设置方法调用的顺序。
测试模拟结构体和 trait
现在你的模拟对象已经准备就绪,是时候看看它如何运行了。请看以下示例:
use mockall::*;
use mockall::predicate::*;
#[automock]
trait MyTrait {
fn foo(&self) -> u32;
fn bar(&self, x: u32) -> u32;
}
fn function_to_test(my_struct: &dyn MyTrait) -> u32 {
my_struct.foo() + my_struct.bar(4)
}
fn main() {
let mut mock = MockMyTrait::new();
mock.expect_foo()
.return_const(44u32);
mock.expect_bar()
.with(predicate::eq(4))
.returning(|x| x + 1);
assert_eq!(49, function_to_test(&mock));
println!("All good!");
}
在此示例中,我们正在测试 function_to_test 函数。该函数接受任何实现了 MyTrait trait 的对象,包括模拟对象。
Mockall 的替代方案
除了 Mockall,Rust 还有其他用于 Mocking 的库。探索这些替代方案可以帮助你为项目找到最合适的库。
下面我们来看几个替代方案。
Mockers
Mockers 受 Google C++ 的 Google Mock 库启发。Mockers 具有高效的语法,支持稳定版 Rust;不过,某些特性(如泛型函数)只能在 nightly Rust 中使用。
Mockers 使用 Scenario(场景) 对象来创建和控制模拟对象。Scenario 对象能让你高效地创建模拟对象。
以下是使用 Mockers 进行模拟的示例:
#[cfg(test)]
mod test {
use mockers::Scenario;
use mockers_derive::mocked;
#[cfg_attr(test, mocked)]
trait MyTrait {
fn do_something(&self, x: i32) -> i32;
}
// 定义使用该 trait 的函数
fn my_function(obj: &dyn MyTrait, x: i32) -> i32 {
obj.do_something(x)
}
// 编写使用模拟对象的测试
#[test]
fn test_my_function() {
// 创建新的模拟对象和场景
let scenario = Scenario::new();
let (my_mock, my_mock_handle) = Scenario::create_mock_for::<dyn MyTrait>(&scenario);
// 定义模拟对象的预期行为
scenario.expect(my_mock_handle.do_something(10).and_return(42));
// 验证模拟对象是否按预期被调用
assert_eq!(42, my_function(&my_mock, 10));
}
}
Mock Derive
Mock Derive 在简化 Mocking 过程方面很有用。即使你使用其他测试系统(如 cargo test),它也能帮你设置单元测试。
Mock Derive 目前还没有稳定版本。截至撰写本文时,它仍处于开发阶段,可能不支持一些现实世界的用例。尽管如此,以下是使用 Mock Derive 进行模拟的示例:
use mock_derive::mock;
// 定义要模拟的 trait
#[mock]
trait MyTrait {
fn do_something(&self) -> i32;
}
// 编写使用模拟对象的测试
#[test]
fn test_my_function() {
// 创建模拟对象的新实例
let mut mock = MockMyTrait::new();
// 在模拟对象上设置期望
mock.method_do_something()
.first_call()
.set_result(32);
// 将模拟对象注入被测函数
let result = mock.do_something();
// 验证模拟对象是否按预期被调用
// mock.assert();
assert_eq!(result, 32);
}
Galvanic Mock
Galvanic-mock 是一个行为驱动的模拟库。它是与 galvanic-test 和 galvanic-assert 一起使用的测试库之一。
Galvanic Mock 允许你完成以下任务:
- 从一个或多个 trait 创建模拟对象
- 根据模式定义模拟对象的行为
- 对与模拟对象的交互设定期望
- 模拟泛型 trait 和带关联类型的 trait
- 模拟泛型 trait 方法
- 将测试与
galvanic-test和galvanic-assert集成
以下是使用 Galvanic Mock 进行模拟的示例:
// `galvanic_mock` 需要 nightly Rust
extern crate galvanic_mock;
use galvanic_mock::{mockable, use_mocks};
#[mockable]
trait MyTrait {
fn do_something(&self, x: i32) -> i32;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[use_mocks]
fn simple_mock_usage() {
// 创建新对象
let mock = new_mock!(MyTrait);
// 为模拟对象定义行为
given! {
<mock as MyTrait>::do_something(|&x| x < 0) then_return_from |&(x,)| x - 1 always;
<mock as MyTrait>::do_something(|&x| x > 0) then_return_from |&(x,)| x + 1 always;
<mock as MyTrait>::do_something(|&x| x == 0) then_return 0 always;
}
// 匹配第一个行为
assert_eq!(mock.do_something(4), 5);
// 匹配第二个行为
assert_eq!(mock.do_something(-1), -2);
// 匹配最后一个行为
assert_eq!(mock.do_something(0), 0);
}
}
Pseudo
Pseudo 是一个轻量级的模拟库。它只提供你进行模拟所需的功能,不多不少。使用 Pseudo,你可以:
- 模拟 trait 实现
- 跟踪函数调用参数
- 设置返回值
- 在测试时覆盖函数
本节提到的一些库具有不稳定特性,这也是某些库只能在 nightly Rust 中使用的原因。
以下是使用 Pseudo 进行模拟的示例:
extern crate pseudo;
use pseudo::Mock;
// 定义要模拟的 trait
trait MyTrait: Clone {
fn do_something(&self, x: i32) -> i32;
}
// 使用该 trait 创建模拟结构体
#[derive(Clone)]
struct MockMyTrait {
pub do_something: Mock<(i32,), i32>,
}
// 为模拟结构体实现该 trait
impl MyTrait for MockMyTrait {
fn do_something(&self, x: i32) -> i32 {
self.do_something.call((x,))
}
}
fn function_to_test<T: MyTrait>(my_trait: &T, x: i32) -> i32 {
my_trait.do_something(x)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn doubles_return_value() {
let mock = MockMyTrait { do_something: Mock::default() };
mock.do_something.return_value(2);
// 测试 `function_to_test`
assert_eq!(function_to_test(&mock, 1), 2);
}
#[test]
fn uses_correct_args() {
let mock = MockMyTrait { do_something: Mock::default() };
assert!(!mock.do_something.called());
function_to_test(&mock, 1);
assert_eq!(mock.do_something.num_calls(), 1);
assert!(mock.do_something.called_with((1,)));
}
}
Wiremock
Wiremock 为与 HTTP API 交互的应用程序提供模拟服务。使用 Wiremock,你可以创建用于测试的模拟 HTTP 服务器。
Wiremock 使用请求匹配和响应模板技术来模拟 HTTP 响应。请求匹配会检查传入的请求是否满足指定条件(这些条件在处理器中指定)。响应模板则用于生成 API 响应的内容。
以下是使用 Wiremock 进行模拟的示例:
#[cfg(test)]
mod test {
use wiremock::{MockServer, Mock, ResponseTemplate};
use wiremock::matchers::{method, path};
#[tokio::main]
#[test]
async fn hello() {
// 在本地随机端口启动模拟 HTTP 服务器
let mock_server = MockServer::start().await;
// 设置模拟服务器的行为
Mock::given(method("GET"))
.and(path("/hello"))
.respond_with(ResponseTemplate::new(200)) // 当收到 '/hello' 的 GET 请求时返回 200 状态
.mount(&mock_server) // 将行为挂载到模拟服务器
.await;
// 使用任意 HTTP 客户端测试模拟服务器是否按预期工作
let status = surf::get(format!("{}/hello", &mock_server.uri()))
.await
.unwrap()
.status();
assert_eq!(status as u16, 200);
}
#[tokio::main]
#[test]
async fn missing_route_returns_404() {
// 在本地随机端口启动模拟 HTTP 服务器
let mock_server = MockServer::start().await;
// 设置模拟服务器的行为
Mock::given(method("GET"))
.and(path("/hello"))
.respond_with(ResponseTemplate::new(200)) // 当收到 '/hello' 的 GET 请求时返回 200 状态
.mount(&mock_server) // 将行为挂载到模拟服务器
.await;
// 测试未注册路由的模拟服务器,应返回 404 状态
let status = surf::get(format!("{}/missing", &mock_server.uri()))
.await
.unwrap()
.status();
assert_eq!(status as u16, 404);
}
}
Faux
Faux 允许你在不使代码复杂化的情况下为结构体创建模拟版本。和其他模拟库一样,Faux 仅推荐用于测试目的。在生产环境中使用模拟对象可能导致不稳定性和生产问题。
Faux 库只模拟结构体的公有方法,不会模拟任何私有方法或字段。仅模拟公有方法可确保模拟对象保持必要的大小。
以下是使用 Faux 进行模拟的示例:
// `faux::create` 使 `MyStruct` 可被模拟
#[cfg(test)]
#[faux::create]
pub struct MyStruct {}
// `faux::methods` 使 `MyStruct` 的所有公有方法可被模拟
#[cfg(test)]
#[faux::methods]
impl MyStruct {
pub fn do_something(&self, x: usize) -> String {
"Result of doing something".to_string()
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn it_works() {
// 使用 `faux` 方法创建 `MyStruct` 的模拟版本
let mut mock = MyStruct::faux();
// 仅当参数为 3 时模拟 fetch
faux::when!(mock.do_something(3)) // 参数匹配器是可选的
.then_return("A third string".to_string()); // 为此模拟存根返回值
assert_eq!(mock.do_something(3), "A third string".to_string());
}
}
Unimock
Unimock 是一种不同类型的模拟库。与其他库不同,Unimock 用同一种类型实现所有生成的模拟对象。相比其他库,这种方法在测试中具有更好的灵活性和效率。
让我们通过一个示例看看 Unimock 如何工作:
use unimock::{MockFn, matching, Unimock, unimock};
// 为 `MyTrait` 创建模拟版本
#[unimock(api = MockMyTrait)]
trait MyTrait {
fn do_something(&self) -> i32;
}
// 编写要测试的函数
fn test_me(mock: impl MyTrait) -> i32 {
mock.do_something()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_function_works() {
// 为 `MockMyTrait.do_something` 编程行为
let clause = MockMyTrait::do_something
.each_call(matching!())
.returns(1337);
// 初始化模拟对象
let mock = Unimock::new(clause);
assert_eq!(1337, test_me(mock));
}
}
Mry
Mry 允许你轻松创建用于单元测试的模拟对象。你可以将 Mry 与任何 Rust 测试框架集成,包括内置的 cargo test。
Mry 是一个易于使用的库,提供了简单的 API 来构造模拟对象。以下是使用 Mry 进行模拟的示例:
// 创建可模拟的结构体 `MyStruct`
#[mry::mry]
struct MyStruct {}
#[mry::mry]
impl MyStruct {
fn do_something(&self, count: usize) -> String {
format!("The trait says {}", count)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn meow_returns() {
// 从 `MyStruct` 初始化模拟对象
let mut mock = mry::new!(MyStruct {});
// 为模拟对象构造行为
mock.mock_do_something(mry::Any)
.returns("Called".to_string());
// 测试模拟对象的行为
assert_eq!(mock.do_something(2), "Called".to_string());
}
}
结论
Mocking 允许开发者隔离被测单元。你可以控制该单元依赖项的行为,使测试更加聚焦。Mocking 在处理复杂系统或外部依赖时特别有用,尤其是在难以控制这些依赖行为的情况下。
在本文中,我们演示了如何在 Rust 中使用 Mockall 创建和使用模拟对象,研究了如何修改模拟对象的行为,并评估了几种替代的模拟库。