Rust 中的 Mocking:Mockall 及其替代方案

更新于 2026-01-15

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 呢?在大多数语言中会产生“除零错误”,因为任何数除以零都是无穷大。
  • 如果 ab 是数组呢?你能确保调用你函数的代码只会传入预期的数据类型吗?不幸的是,你不能。

这就是单元测试发挥作用的地方。单元测试会从多个角度测试你的代码,确保它能处理这类异常情况。

当然,单元测试不会自动完成这些工作——你必须自己编写这些测试用例。

例如,为了测试上述除法函数,你可以编写如下测试用例:

  • 预期 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);

在这段代码中,我们修改了 mockfoobar 方法的行为:

  • 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-testgalvanic-assert 一起使用的测试库之一。

Galvanic Mock 允许你完成以下任务:

  • 从一个或多个 trait 创建模拟对象
  • 根据模式定义模拟对象的行为
  • 对与模拟对象的交互设定期望
  • 模拟泛型 trait 和带关联类型的 trait
  • 模拟泛型 trait 方法
  • 将测试与 galvanic-testgalvanic-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 创建和使用模拟对象,研究了如何修改模拟对象的行为,并评估了几种替代的模拟库。