CppCon 2017 学习:Mocking Frameworks Considered
当然可以,下面是对 Fowler 的 Whiskey-Store 示例。
Fowler 的 Whiskey-Store 示例(坏设计)
贴出的类图是 Martin Fowler 在《重构》书中使用的一个教学用反面案例(故意设计得不合理),用来说明如何通过重构改善代码设计。
类图解析(你的 Mermaid 图)
为何说是“坏设计”?
1. 类的职责不清晰(违反单一职责原则)
Order
这个类不仅代表订单,还负责自己去调用仓库去处理库存。- 它不只是数据对象,还承担业务逻辑(库存检查、扣减等),职责太多。
2. 耦合过高
Order
类直接调用Warehouse
的方法(hasInventory()
、remove()
)。- 如果以后库存不再由
Warehouse
处理,修改成本就高。
3. 违反迪米特法则(Law of Demeter)
- “最少知道原则”:一个类应该尽量少知道其他类的内部结构。
- 但
Order
知道太多关于Warehouse
的细节。
4. 难以扩展
- 现在的
fillFrom()
方法只支持一个仓库,无法轻松支持:- 多个仓库轮询
- 库存不足时的部分发货
- 优先级策略、折扣策略等
更好的设计建议
应该增加一个服务类来处理“订单如何从仓库发货”的逻辑,订单和仓库只保留各自的职责。
改进后的类图:
总结
项目 | 坏设计版本 | 好设计版本 |
---|---|---|
类职责 | 混合业务逻辑和数据 | 职责清晰,分离业务逻辑 |
耦合程度 | Order 和 Warehouse 强耦合 | 使用中间服务类降低耦合 |
可扩展性 | 扩展困难 | 通过服务类轻松扩展策略 |
可读性 | 命名、结构都容易让人迷惑 | 清晰,易于维护 |
#两个 Mermaid 图(状态图 + 时序图)展示的是 Order 对象的期望行为(intended behavior),用于理解其在“填充订单”流程中的状态变化和与 Warehouse (仓库)的交互。 | ||
这部分内容通常用于单元测试设计,特别是带有副作用或状态变化的对象(Stateful APIs): |
第一部分:状态图(stateDiagram)
这是订单执行 fillFrom(wh)
方法时的状态转移流程图:
- Start:开始执行
fillFrom()
。 - Checking:调用
wh.hasInventory(what, howmany)
检查仓库是否有库存。 - 根据检查结果:
- 有库存(true):
- 进入
Removing
状态,调用wh.remove(what, howmany)
扣除库存; - 设置
filled = true
,订单被成功填充。
- 进入
- 无库存(false):
- 设置
filled = false
,订单未被填充。
- 设置
- 有库存(true):
这是一个典型的有状态流程,适合用于行为测试或状态验证。
第二部分:时序图(sequenceDiagram)中文解析
中文解释:
这是一个方法调用的时间顺序图(Sequence Diagram),用于验证 Order
(或它背后的服务逻辑)与 Warehouse
的交互。
参与者:
order
: 外部调用者或测试对象SUT
(System Under Test):测试中的对象,可能是Order
或OrderService
Warehouse
: 依赖的仓库对象(DOC = Depended-on Component)
调用流程:
order
调用SUT.Fill()
。SUT
向Warehouse
查询库存:hasInventory("TALISKER", 50)
。- 如果返回
true
:- 调用
remove("TALISKER", 50)
从库存中移除; - 调用
setFilled()
设置订单已填充;
- 调用
- 返回结果给
order
。
单元测试中的应用:Mock 和验证交互
这个图用于说明如何测试 SUT(比如 Order)是否正确调用了 Warehouse,这就是交互测试(Interaction Testing)。
重点:
- 使用 Mock 对象 替代真实的
Warehouse
。 - 验证是否:
- 调用了
hasInventory()
; - 在库存充足时调用了
remove()
; - 正确地设置了订单的
filled
状态; - 调用顺序是正确的(先检查库存,后移除);
- 调用了
- 这就是所谓的:
“验证 Order(SUT)对 Warehouse(DOC)的调用顺序和使用方式”
总结:
内容 | 解释 |
---|---|
stateDiagram | 显示订单在不同库存条件下的状态变化流程 |
sequenceDiagram | 显示 Order 与 Warehouse 之间的调用时序与交互 |
Mock 的用途 | 模拟 Warehouse ,验证其被正确调用,不用真实对象 |
“SUT”和“DOC”的含义 | SUT = 测试的对象(如 Order),DOC = 被依赖的组件(如仓库) |
测试重点 | 验证行为逻辑 + 方法调用顺序 + 状态变化是否符合预期 |
你这段内容的重点是说明测试含有依赖组件(DOC)的系统(SUT),并用 Mermaid 图形象地表达测试流程及交互关系。下面帮你梳理总结和理解: |
Mermaid 图结构简要说明
- Setup 初始化测试环境,传入直接输入(控制点)
- SUT(System Under Test)即被测试系统
- Exercise 是测试操作调用 SUT
- Verify 验证测试结果和系统状态
- Teardown 测试清理阶段
- Fixture_DOC(Dependent Other Component)是 SUT 依赖的其他组件,测试时用以模拟或控制
- 图中用“Direct Inputs”、“Indirect Inputs”、“Observation Points”等术语区分输入输出类型
术语解释
- DOC:Dependent Other Component,依赖的外部组件或系统,通常被模拟(mock)以实现测试隔离
- SUT:System Under Test,测试对象
- Direct Inputs (Control Points):直接控制 SUT 的输入
- Indirect Inputs (Control Point):通过依赖组件间接控制的输入
- Direct Outputs (Observation Points):直接从 SUT 可观察的输出
- Indirect Outputs (Observation Points):通过依赖组件间接观察的输出
- Get Something (with return value):依赖组件返回的数据
- Do Something (no return value):依赖组件执行的动作但无返回
理解重点
- 测试设计通过分离 SUT 和它的依赖(DOC),实现解耦
- 依赖组件的行为通过间接输入/输出被控制和观察
- 测试通过直接调用和间接调用,验证 SUT 的行为和状态
- 这种设计有助于编写可维护且可靠的测试,特别是面对复杂依赖时
测试流程详细说明
1. Setup(测试初始化)
- 作用:搭建测试环境,准备所有必要的输入和依赖。
- 具体内容:
- 初始化直接输入(Direct Inputs),即测试代码将直接传给被测系统(SUT)的参数或配置。
- 设置控制点(Control Points),用来控制测试的条件和行为。
- 目的是让测试有一个确定且受控的起点,保证测试的可重复性。
2. SUT(被测试系统)
- 作用:被测的核心系统或模块,执行被测试的业务逻辑。
- 特点:
- 依赖于外部组件(Fixture_DOC)来完成部分功能。
- 接受来自 Setup 的直接输入,以及来自依赖组件的间接输入。
- 这是测试的目标对象,所有测试操作都围绕它展开。
3. Exercise(测试执行)
- 作用:执行具体的测试动作,比如调用 SUT 的接口或方法。
- 具体行为:
- 向 SUT 发出操作请求。
- SUT 返回结果(with return value)。
- 通过模拟实际使用场景触发系统逻辑,产生输出供后续验证。
4. Verify(测试验证)
- 作用:对测试执行的结果进行验证,判断是否符合预期。
- 具体工作:
- 观察 SUT 的直接输出(Direct Outputs)——比如返回值、状态变化等。
- 获取 SUT 的内部状态(Get State),确认系统内部是否正确更新。
- 同时也可以观察依赖组件的输出,验证它们是否被正确调用和响应。
- 这是判断测试是否通过的关键阶段。
5. Teardown(测试清理)
- 作用:清理测试环境,释放资源,保证测试间相互独立。
- 工作内容:
- 恢复被测试系统和依赖组件到初始状态。
- 关闭或重置测试期间创建的对象和连接。
6. Fixture_DOC(依赖组件)
- 作用:模拟或封装 SUT 的依赖系统,帮助测试隔离和控制复杂依赖。
- 交互细节:
- Indirect Input:对依赖组件下达控制命令(Do Something),不返回值,用来模拟依赖行为。
- Indirect Output:从依赖组件获取数据(Get Something)或观察依赖状态,辅助验证。
- 通过控制和观察依赖,帮助检测 SUT 在各种依赖响应情况下的表现。
总结
这套测试设计遵循了“解耦依赖”的原则:
- Setup 提供确定的起点和直接控制。
- Exercise 触发系统动作。
- Verify 多角度验证系统行为和状态。
- Fixture_DOC 模拟依赖,使测试更可控、准确。
- Teardown 保证测试环境整洁。
为什么在测试中需要用到 stub 或 mock的原因,核心点可以总结和解释如下:
为什么需要 Stub/Mock?
- 真实对象行为不可预测(Nondeterministic)
真实对象可能产生不稳定、随机的结果,比如股票行情数据、网络请求的状态等,这会导致测试结果不稳定。 - 真实对象难以搭建(Difficult to Set Up)
真实对象可能需要复杂的环境或者大量资源来运行,比如硬件设备、数据库或远程服务,测试环境难以复现。 - 真实对象行为难以触发(Hard to Trigger)
真实对象的某些行为或错误状态很难在测试中触发,比如网络故障、内存溢出、特定异常等。 - 真实对象运行缓慢(Slow)
真实对象执行速度慢会拖慢测试速度,不利于频繁执行单元测试。 - 真实对象是用户界面(User Interface)
界面交互难以自动化,且界面行为不适合用作底层逻辑测试。 - 测试需要验证真实对象的使用情况
比如确认某个回调函数是否被调用,或者确认接口调用顺序和参数。 - 真实对象尚不存在(Not Yet Implemented)
测试先于依赖组件的开发,依赖未完成时需要模拟其行为。
示例说明
- 股票行情推送常有不确定数据,难以预测结果。
- 真实网络错误或资源限制可能导致不同的异常。
- 硬件动作(如马达运动)难以用模拟环境稳定复制。
- 用户本人交互难以在自动化测试中完全复现。
- 需要确认回调是否执行,例如事件监听器。
- 当与其他团队或新硬件系统协作时,依赖组件可能还未完成,必须用模拟代替。
总结
使用 stub 和 mock 的目的是让测试变得:
- 可控:排除不可预见因素影响,测试更稳定。
- 高效:避免复杂依赖和慢速操作。
- 可验证:能验证交互细节和调用顺序。
- 提前测试:即使依赖未完成,也能开展测试。
“Test double patterns”实际上就是测试替身(Test Double)的各种形式和目的,主要用来解决两个关键问题:
1. 如何在依赖代码不可用时,独立验证逻辑?
- 当你要测试的代码依赖某些外部组件或模块,但这些依赖:
- 尚未开发完成,
- 不稳定,
- 行为不可预测,
- 难以搭建,
这时候就需要用**测试替身(Test Doubles)**来模拟这些依赖,确保测试的逻辑独立、可靠。
- 通过使用 Stub、Mock、Fake、Spy 等不同类型的测试替身,替代真实依赖,模拟特定的行为和响应,保证你能专注于验证目标代码本身的业务逻辑。
2. 如何避免测试变慢?
- 真实依赖可能涉及网络请求、数据库操作、文件IO、硬件设备等,执行速度慢。
- 使用测试替身可以:
- 快速返回预设的响应,
- 避免真正调用外部系统,
- 极大提升测试执行速度,
- 使得单元测试能频繁、快速执行,方便持续集成。
总结
**测试替身模式(Test Double Patterns)**的核心价值是:
- 隔离依赖:独立验证代码逻辑,不受外部因素干扰。
- 提升效率:加速测试运行,避免慢依赖。
- 控制场景:轻松模拟各种边界条件和异常情况,方便全面测试。
Mock Object Patterns 主要解决的问题
1. 如何对 SUT(被测试系统)间接输出进行行为验证?
SUT 有时候不会直接返回结果,而是通过调用其他组件(依赖)来完成某些操作。Mock 对象充当这些依赖,帮我们检测:
- SUT 是否调用了依赖的正确方法,
- 调用了多少次,
- 传递了什么参数。
这就是行为验证(Behavior Verification),通过观察与 Mock 的交互确认 SUT 的行为是否符合预期。
2. 如何在依赖外部组件的情况下,独立验证业务逻辑?
业务逻辑往往依赖其他组件的输入(间接输入),这些依赖可能难以搭建或不稳定。Mock 对象:
- 模拟这些依赖的行为和返回值,
- 让我们在隔离环境下,专注于验证 SUT 自身逻辑,
- 免去对外部组件的真实依赖,提高测试的可靠性和执行速度。
简单说:
- 行为验证是用 Mock 监视依赖被正确调用。
- 独立验证是用 Mock 替代依赖,模拟输入,专注测试核心逻辑。
“Configurable Test Double”就是指那种可以在测试准备阶段(fixture setup)配置好行为和期望的测试替身,比如设置它返回什么数据或者期望被调用多少次、参数是什么。
关键点:
- 配置复用:Test Double 在测试开始前配置好返回值或调用期望,可以在多个测试中复用相同配置。
- 方便测试:通过配置减少每次测试重复写代码,让测试更简洁、可维护。
- 滥用风险:有些 Mock 框架过度依赖这种“配置式”Mock,导致测试变得复杂且难以理解,尤其是当配置很复杂时,测试变得难以维护。
总结:
Configurable Test Double 很方便,但要合理使用,避免复杂配置堆积,影响测试清晰度和稳定性。
关于Mocking Framework的核心功能和设计思想,简单来说:
什么是 Mocking Framework?
1. 依赖注入 (Dependency Injection)
- 通常需要通过依赖注入,将被测试代码(SUT)中对真实依赖(DOC)的引用替换成测试替身(Test Double)。
- 这一步有时需要改造代码以支持依赖注入,也就是“引入接缝(Introduce Seam)”,方便替换。
2. 替换真实依赖(DOC)
- Mock 框架帮你自动替换依赖,用测试替身代替真实的依赖组件(DOC)。
- 这样测试时可以控制依赖的行为。
3. 伪造依赖结果
- 框架可以让你轻松伪造依赖的函数返回值,帮助 SUT 在测试中获得期望的输入。
4. 跟踪调用和参数
- Mock 对象会记录 SUT 调用的函数名、调用顺序和传入参数,方便后续验证。
5. 验证调用顺序和参数匹配
- 可以验证 SUT 是否按照预期顺序调用了依赖的函数,参数是否正确。
总结:
Mocking Framework 是测试替身的自动生成和管理工具,核心价值是:
- 简化依赖注入与替换,
- 方便伪造和控制依赖行为,
- 自动跟踪和验证调用细节,
- 支持行为验证和交互测试。
Introducing Seams(引入接缝)的核心点:
什么是 Seam?
- Seam(接缝) 是代码中可以改变行为的“切入点”,不用修改原代码逻辑,就能插入或替换行为。
常见的Seam类型:
- Object Seam(对象接缝)
- 通过接口或继承的虚方法实现。
- 把依赖(DOC)和测试替身(Test Double)通过构造函数参数传入。
- 测试时传入替身,运行时传入真实依赖。
- Compile Seam(编译接缝)
- 利用模板参数。
- 生产代码里,模板参数是默认的真实依赖。
- 测试时用模板参数替换为测试替身。
- Linker Seam / Preprocessor Seam(链接器接缝 / 预处理器接缝)
- 用链接器替换符号或用预处理器条件编译。
- 常作为“最后手段”,尤其是 C 语言函数的替代方案。
为什么重要?
- 允许我们在不动现有代码的情况下插入测试替身。
- 让代码更灵活,更容易做单元测试,尤其是遗留代码。
你这两个 mermaid
类图展示了通过 Extract Interface Refactoring(提取接口重构) 实现 可 Mock 化(Mockability) 的关键步骤,非常标准且清晰。
第一张图(重构前):
问题:
Order
直接依赖具体类Warehouse
。- 在测试中不能用
MockWarehouse
替代,耦合紧密,不可 Mock。
第二张图(重构后):
优点:
Order
不再依赖具体类Warehouse
,而是依赖接口IWarehouse
。- MockWarehouse 实现了 IWarehouse,可以用于单元测试。
- 这使得
Order
更容易测试(可替换、可隔离、可模拟),同时遵循了依赖倒置原则(DIP)。
总结:
原则/技术名 | 说明 |
---|---|
Extract Interface | 提取公共接口,减少耦合 |
Mock Object | 模拟外部依赖以隔离测试 |
DIP(依赖倒置) | 高层模块依赖抽象,不依赖具体类 |
Test Double | 模拟或伪造依赖的通用术语 |
Seam | 在此处引入 mock,用于测试控制点 |
你提供的 C++ 代码展示了一个非常清晰的 “Extract Interface” 重构 实例,用于实现 可测试性和解耦,尤其是在单元测试中引入 mock 的典型场景。
你做了什么?
你将原本紧耦合的代码:
struct Order {void fill(Warehouse& warehouse); // 直接依赖具体类
};
重构为:
struct Order {void fill(WarehouseInterface& warehouse); // 依赖接口
};
并提取出接口:
struct WarehouseInterface {virtual ~WarehouseInterface() {}virtual int getInventory(std::string const & s) const = 0;virtual void remove(std::string const & s, int i) = 0;virtual void add(std::string const & s, int i) = 0;virtual bool hasInventory(std::string const & s, int i) const = 0;
};
让原本的 Warehouse
成为接口的实现:
struct Warehouse : WarehouseInterface {// 实现所有接口方法
};
为什么这么做?
原因 | 解释 |
---|---|
测试解耦 | Order 不再依赖具体的 Warehouse ,因此可以传入一个 MockWarehouse 来测试 Order 的逻辑。 |
引入 Mock/Stub | 你可以为 WarehouseInterface 创建一个 MockWarehouse 来模拟行为、跟踪调用。 |
遵循 SOLID 中的 DIP | Order(高层模块)依赖的是抽象(接口),而不是低层细节(具体类)。 |
单一职责与模块化设计 | 更清晰的边界,有助于代码维护和扩展 |
Mock 示例(简化版):
struct MockWarehouse : WarehouseInterface {bool wasRemoveCalled = false;int getInventory(const std::string& s) const override {return 100;}void remove(const std::string& s, int i) override {wasRemoveCalled = true;}void add(const std::string& s, int i) override {}bool hasInventory(const std::string& s, int i) const override {return true;}
};
// 测试
Order o;
MockWarehouse mock;
o.fill(mock);
assert(mock.wasRemoveCalled);
总结关键术语
名称 | 意义 |
---|---|
Interface Extraction | 提取接口用于打断依赖,方便替换 |
WarehouseInterface | 提取出的 seam,用于解耦 |
MockWarehouse | 测试用的 double,实现接口,用于模拟依赖 |
Dependency Injection | 通过参数将依赖注入,而非内部创建 |
DIP(依赖倒置原则) | 高层依赖抽象,低层实现接口 |
代码展示了 使用 Fake Object 测试意图行为(intended behavior) 的经典做法,我来帮你总结和解释下其中的概念、关键点和作用。
目标:验证 Order
的预期行为(intended behavior)
你希望测试:
如果仓库中没有足够的库存(即空仓库),调用
order.fill()
后,Order
不应该被填充。
解决方法:创建一个 Fake
仓库类
你定义了一个空的仓库实现:
struct EmptyWarehouse : WarehouseInterface {void add(std::string const &, int) override {}int getInventory(std::string const &) const override { return 0; }bool hasInventory(std::string const &, int) const override { return false; }void remove(std::string const &, int) override {}
};
这个类:
- 实现了接口
WarehouseInterface
- 明确表示“没有库存”,即“库存为0”、“没有任何商品”
- 是一个 Fake Object(并非 Mock,也非 Stub):它实现了正确的业务规则的简化逻辑
测试代码解释
void OrderFillFromWarehouse() {Order order(TALISKER, 50);EmptyWarehouse warehouse{};order.fill(warehouse);ASSERT(not order.isFilled());
}
这个测试用例:
- 使用
EmptyWarehouse
模拟实际仓库 - 检查在库存不足的情况下,
order.isFilled()
返回false
- 验证
Order
的逻辑是正确的(它依赖仓库回答是否有货)
Fake 的意义 vs Stub/Mock
类型 | 用途 | 特点 |
---|---|---|
Stub | 返回预设值 | 不检查交互,仅提供数据 |
Fake | 实现简化逻辑 | 具备一定行为逻辑,如 EmptyWarehouse |
Mock | 验证行为 | 跟踪调用/断言交互是否发生 |
你这里用的是 Fake:行为受控、但符合逻辑规则。 |
总结
Order
遵循依赖倒置,通过接口解耦EmptyWarehouse
是一个 Fake,用于测试Order
的行为- 你测试的是:在仓库无货时,订单不会被填充
- 你无需依赖真实的仓库数据、状态或副作用
C++ 中为单元测试引入 Mock 的准备过程,特别是通过 提取接口(Extract Interface) 与使用 Mock 框架(如 Google Mock / Trompeloeil)。我来详细解释关键要点。
背景:为什么要 Extract Interface?
你有一个类 Warehouse
,它在生产代码中被广泛使用,现在你想对依赖它的 Order
类进行单元测试。但是:
Warehouse
太复杂,或者依赖外部资源(比如数据库或网络)- 你不想在测试中使用真实对象
- 你想测试
Order
的行为是否 正确调用了Warehouse
的方法(行为验证)
解法:提取接口 WarehouseInterface
struct WarehouseInterface {virtual int getInventory(std::string const & s) const = 0;virtual void remove(std::string const & s, int i) = 0;virtual void add(std::string const & s, int i) = 0;virtual bool hasInventory(std::string const & s, int i) const = 0;
};
现在你可以让:
Warehouse
实现这个接口(用于生产环境)MockWarehouse
实现这个接口(用于测试)
Mock 实现:两种框架风格
你展示了两种语法,其实分别是:
1. Google Mock 风格(MOCK_METHOD
, MOCK_CONST_METHOD
):
struct MockWarehouse : WarehouseInterface {MOCK_CONST_METHOD1(getInventory, int(std::string const&));MOCK_METHOD2(remove, void(std::string const&, int));MOCK_METHOD2(add, void(std::string const&, int));MOCK_CONST_METHOD2(hasInventory, bool(std::string const&, int));
};
- 使用 Google Mock 宏
- 在测试中你可以设置预期调用、断言调用顺序等
- 例如:
EXPECT_CALL(mock, hasInventory("item", 50)).WillOnce(Return(true));
2. Trompeloeil 风格(MAKE_MOCK
, MAKE_CONST_MOCK
):
struct MockWarehouse : WarehouseInterface {MAKE_CONST_MOCK1(getInventory, int(std::string const&), override);MAKE_MOCK2(remove, void(std::string const&, int), override);MAKE_MOCK2(add, void(std::string const&, int), override);MAKE_CONST_MOCK2(hasInventory, bool(std::string const&, int), override);
};
- Trompeloeil 是另一个现代的 C++ mocking 框架,语法略有不同
- 特点:使用 C++14/17 的特性,报错信息更清晰,类型安全强
模拟场景:测试 Order
使用 MockWarehouse 的行为
TEST(OrderTest, FillsWhenInventoryIsAvailable) {MockWarehouse mock;EXPECT_CALL(mock, hasInventory("TALISKER", 50)).WillOnce(Return(true));EXPECT_CALL(mock, remove("TALISKER", 50));Order order("TALISKER", 50);order.fill(mock);ASSERT_TRUE(order.isFilled());
}
- 测试验证了:
Order
是否调用了正确的hasInventory
和remove
方法Order
的状态最终是否为filled
总结:引入 Mock 的步骤
步骤 | 目的 |
---|---|
提取接口 WarehouseInterface | 为生产对象和测试替身解耦 |
使用 Mock 框架(Google Mock / Trompeloeil) | 自动实现接口,用于测试 |
编写期望调用 (EXPECT_CALL ) | 验证 SUT 与依赖的交互 |
在测试中注入 Mock | 使用依赖注入实现可测性 |
这句代码:
EXPECT_CALL(mock, hasInventory("item", 50)).WillOnce(Return(true));
是使用 Google Mock 框架写的一行行为期望设置(expectation)。它的作用可以分解如下 :
作用详解:
1. 指定调用期望:
这句代码表示:
在测试执行过程中,期望
mock
对象上的hasInventory("item", 50)
方法被调用一次。
如果这个调用没发生,测试就会失败。
2. 指定返回值:
当这个方法被调用时,返回
true
。
这相当于“伪造”了一个依赖组件的行为 —— 我们模拟了 Warehouse
的行为,使它声称有足够库存。
3. 限制调用次数:
使用 WillOnce(...)
表示:
只允许调用一次,如果多次调用也会导致测试失败(除非你用
WillRepeatedly(...)
)。
示例上下文:
假设你在测试一个类 Order
,它会调用 hasInventory()
来检查库存:
Order order("item", 50);
order.fill(mock);
ASSERT_TRUE(order.isFilled());
你希望测试 Order::fill()
是否能:
- 检查仓库有没有库存(
hasInventory(...)
) - 在库存充足时正确设置
filled = true
为什么有用?
这段 EXPECT_CALL(...).WillOnce(...)
的意义在于:
- 它将测试 从真实仓库解耦
- 它让你可以精确控制依赖对象的行为
- 它允许你验证调用是否发生了(行为验证)
总结一句话:
EXPECT_CALL(mock, hasInventory("item", 50)).WillOnce(Return(true));
意思是:
“我期望系统在运行过程中,调用
mock.hasInventory("item", 50)
方法一次,并返回true
。如果没发生,测试就失败。”
这是单元测试中模拟(mocking)依赖并验证行为的典型方式。
给出的代码和说明展示了如何使用 Mocking 框架(如 Google Mock 和 Trompeloeil)来模拟依赖行为并进行单元测试。下面是逐段解释和对比,帮助你深入理解:
背景情景:测试 Order
的行为
目标:测试 Order::fill()
方法在仓库没有库存的情况下能否正确设置状态为“未填充”。
使用 Fake 的方式(手写实现)
bool hasInventory(std::string const & s, int i) const {return false;
}
void OrderFillFromWarehouse(){Order order(TALISKER, 50);EmptyWarehouse warehouse{}; // 手写的 fake,始终返回 falseorder.fill(warehouse);ASSERT(not order.isFilled());
}
特点:
- 你手动实现了一个
EmptyWarehouse
类来控制返回值; - 用的是状态验证(检查 order 的状态);
- 简单、易懂,但无法验证是否真的调用了
hasInventory()
—— 即无法做行为验证。
使用 GMock/GTest 实现行为验证:
TEST(OrderTest, EmptyWarehouse) {MockWarehouse warehouse{};Order order{TALISKER, 50};EXPECT_CALL(warehouse, hasInventory(TALISKER, 50)).WillOnce(Return(false)); // 设置行为期望order.fill(warehouse);ASSERT_FALSE(order.isFilled());
}
说明:
MockWarehouse
是用 Google Mock 定义的类;EXPECT_CALL(...)
是行为验证:确保Order
的代码中确实调用了hasInventory()
,且参数正确;WillOnce(Return(false))
是返回假的库存信息;- 更严谨的测试:能验证“怎么被调用”。
ON_CALL 的对比:
ON_CALL(warehouse, hasInventory(TALISKER, 50)).WillByDefault(Return(false));
区别:
ON_CALL
设置的是默认行为(默认返回值);- 如果没有
EXPECT_CALL
,会发出运行时警告,提醒你没验证行为; - 建议搭配使用:用
ON_CALL
设置默认,用EXPECT_CALL
做验证。
Trompeloeil 版本(C++ mock 框架):
void testMockingWithTrompeloeil(){MockWarehouse wh;//REQUIRE_CALL(wh, hasInventory(TALISKER, 50)).RETURN(false).TIMES(1);ALLOW_CALL(wh, hasInventory(TALISKER, 50)).RETURN(false);Order order(TALISKER, 50);order.fill(wh);ASSERT(not order.isFilled());
}
区别:
REQUIRE_CALL
:严格行为验证(必须发生,参数必须匹配,调用次数也必须一致);ALLOW_CALL
:类似于ON_CALL
,用于放宽验证,仅设定行为;- Trompeloeil 语法清晰,更现代,常用于 C++20 项目;
- 同样达到了 mock 的目的,但更易集成于现代 C++。
总结理解:
比较项 | Fake(手写类) | GMock (EXPECT_CALL ) | Trompeloeil (REQUIRE_CALL ) |
---|---|---|---|
控制依赖行为 | |||
行为验证(是否调用) | |||
严格性 | 可选(ON_CALL vs EXPECT_CALL) | (REQUIRE_CALL) | |
易用性 | 简单但功能弱 | 强大,语法稍繁 | 强大,现代语法 |
提供的这部分是关于 Behavior Verification(行为验证) 的实战案例,分别用 Google Mock 和 Trompeloeil 展示了“顺序期望”的使用,来验证代码是否以正确的顺序调用依赖对象(DOC)的方法。下面是详尽解析和理解。
什么是 Behavior Verification?
行为验证 = 确保 被测系统(SUT) 与 依赖组件(DOC) 交互的方式符合期望
比如:
- 调用了哪些方法?
- 顺序是否正确?
- 参数是否一致?
- 调用了几次?
示例背景
你在测试 Order::fill()
方法,依赖一个 Warehouse
:
hasInventory()
:判断库存是否足够remove()
:从库存中移除物品
你希望测试:
- 是否调用了这两个方法?
- 顺序是否正确?(先查,再删)
Order
最终是否被设置为已填充?
Google Mock 示例详解
TEST(OrderTest, FilledWarehouse)
{MockWarehouse warehouse{};InSequence s{}; // 顺序期望启用EXPECT_CALL(warehouse, hasInventory(TALISKER, 50)).WillOnce(Return(true));EXPECT_CALL(warehouse, remove(TALISKER, 50));Order order{TALISKER, 50};order.fill(warehouse);ASSERT_TRUE(order.isFilled());
}
关键点解释:
元素 | 含义 |
---|---|
InSequence s{} | 所有 EXPECT_CALL 的调用顺序必须严格匹配 |
EXPECT_CALL(...).WillOnce(...) | 设置期望调用 + 返回值 |
remove(...) 不设置返回值 | void 函数只验证调用本身 |
ASSERT_TRUE(order.isFilled()) | 状态验证(结合行为验证) |
Trompeloeil 示例详解
void testFulledOrderWithTrompeloeil(){MockWarehouse wh{};trompeloeil::sequence seq{};REQUIRE_CALL(wh, hasInventory(TALISKER, 50)).RETURN(true).TIMES(1).IN_SEQUENCE(seq);REQUIRE_CALL(wh, remove(TALISKER, 50)).TIMES(1).IN_SEQUENCE(seq);Order order(TALISKER, 50);order.fill(wh);ASSERT(order.isFilled());
}
Trompeloeil 写法对比:
Trompeloeil | Google Mock |
---|---|
REQUIRE_CALL(...) | EXPECT_CALL(...) |
.IN_SEQUENCE(seq) | InSequence s{} + 顺序写法 |
.RETURN(...) | .WillOnce(Return(...)) |
.TIMES(1) (默认一次) | 可选 .Times(1) |
行为验证的挑战和副作用
Peter Sommerlad 强调的几点缺陷:
问题 | 说明 |
---|---|
DOC 不存在 | 你必须先 mock 一个还没实现的组件,可能会限制设计自由 |
脆弱测试 Fragile | 如果 SUT 的内部调用顺序改变(但功能不变),测试就失败 |
过度规范 Over-specification | 规定太多细节,使得测试阻碍了重构 |
“硬测试”阻碍重构 | 测试太依赖 SUT 的具体实现,难以随设计演进 |
小结
框架 | 行为验证支持 | 顺序验证 | 接口模拟能力 |
---|---|---|---|
GMock | InSequence | MOCK_METHOD 系列 | |
Trompeloeil | .IN_SEQUENCE() | REQUIRE_CALL |
行为验证适合用于验证交互是否符合预期,但也要适度使用,避免影响设计和重构灵活性。
给出的内容是 Trompeloeil(一个现代 C++ mocking 框架)的速查表,主要用于帮助开发者快速写出模拟函数和行为验证。下面我将清晰地解释其设计理念和 DSL(领域特定语言)结构,帮助你全面理解。
问题背景:为何需要 DSL 指定行为?
- 在 Mock 对象中,我们要告诉它:
- “什么样的调用是合法的?”
- “什么情况下返回什么值?”
- “是否调用过?调用了几次?”
- “调用顺序如何?”
为了灵活且可组合地描述这些行为,Mock 框架引入了小型 DSL,即你看到的.RETURN(...)
,.WITH(...)
,.IN_SEQUENCE(...)
等语法。
Trompeloeil Mock 函数生成宏
用法 | 说明 |
---|---|
MAKE_MOCKn(name, sig) | 生成 非 const 成员函数 mock |
MAKE_CONST_MOCKn(name, sig) | 生成 const 成员函数 mock |
n 是参数个数,比如 MAKE_MOCK2(foo, void(int, double)) |
期望设置:REQUIRE / ALLOW / FORBID
宏 | 说明 |
---|---|
REQUIRE_CALL(obj, func(params)) | 强制调用一次,否则测试失败 |
ALLOW_CALL(obj, func(params)) | 允许调用,不会强制 |
FORBID_CALL(obj, func(params)) | 禁止调用,若调用会失败 |
可加 NAMED_ 版本,用于命名和追踪期望对象(通过指针引用) | |
默认行为是:所有调用都是非法的,除非明确设定允许或期望。 |
附加条件 / 动作 DSL
语法 | 说明 |
---|---|
.WITH(cond) | 参数必须满足条件(参数只读) |
.SIDE_EFFECT(stmt) | 每次调用执行一段副作用语句 |
.RETURN(expr) | 返回值(参数只读) |
.THROW(expr) | 抛出异常(参数只读) |
.LR_* 系列 | 参数为 可变引用(可被修改)时使用 |
调用次数和顺序
语法 | 说明 |
---|---|
.TIMES(n) | 期望被调用 恰好 n 次 |
.TIMES(min, max) | 允许被调用 在 min 到 max 次之间 |
.AT_MOST(x) / .AT_LEAST(x) | 调用次数的便捷方式 |
.IN_SEQUENCE(seq) | 与其他期望按指定顺序匹配(顺序验证) |
trompeloeil::sequence seq;
REQUIRE_CALL(obj, method1(_)).IN_SEQUENCE(seq);
REQUIRE_CALL(obj, method2(_)).IN_SEQUENCE(seq);
参数匹配器(Matchers)
通用匹配器:
匹配器 | 意义 |
---|---|
_ | 任意值 |
eq(x) | 等于 x |
ne(x) | 不等于 x |
lt(x) | 小于 x |
le(x) | 小于等于 x |
gt(x) | 大于 x |
ge(x) | 大于等于 x |
re(x) | 正则匹配 /x/ |
示例:
REQUIRE_CALL(mock, func(eq("item"), ge(50)));
生命周期验证
你可以验证 Mock 对象是否被销毁:
auto obj = new deathwatched<MockWarehouse>();
REQUIRE_DESTRUCTION(*obj); // 测试通过仅在析构时调用
小结:Mock DSL 的语义结构
REQUIRE_CALL(mock, method(_)).WITH([](auto x) { return x > 0; }) // 参数过滤.RETURN(42) // 返回值.SIDE_EFFECT(counter++) // 副作用.IN_SEQUENCE(seq) // 顺序控制.TIMES(2); // 调用次数
这种结构清晰定义了:
- 调用的合法性(是否允许)
- 返回什么(行为)
- 在什么条件下(参数)
- 什么时候(顺序)
- 多少次(频度)
关于使用 Mockator 进行简化 Mock 测试的方式,以及其背后的理念,结合 Kent Beck 和 Ward Cunningham 提出的 “Do the simplest thing that could possibly work” 原则。以下是对该内容的详细理解和解释。
理念背景:当你不知道怎么做时
“Do the simplest thing that could possibly work”
这是 Kent Beck 和 Ward Cunningham 在推动 极限编程(XP) 和 测试驱动开发(TDD) 时提出的指导思想,意思是:
- 先实现最小可行解法。
- 不要一开始就追求完美或泛化。
- 尤其在写测试或构建 Mock 时,不要引入不必要的复杂度。
Mockator 就体现了这个理念:
Mockator 的简化 Mock 特性
Mockator 是一种模拟工具/框架,具有如下特点:
特性 | 说明 |
---|---|
无需大量 #define 宏 | 相比 Trompeloeil、GMock 等依赖宏定义,Mockator 更偏向常规 C++ 代码风格 |
使用 IDE(如 Cevelop)生成代码 | Seam 和调用跟踪的 Mock 代码由 IDE 自动生成 |
直接用 std::vector<call> 记录调用 | Mock 的行为跟踪通过 std::vector<call> |
可用正则匹配调用 | 使用 std::regex 实现灵活匹配 |
示例代码解析
MockWarehouse warehouse { };
OrderT<MockWarehouse> order(TALISKER, 50);
order.fill(warehouse);
ASSERT(order.isFilled());
此处使用了 模板参数化的 OrderT,将 Warehouse
替换成 MockWarehouse
,引入了测试 seam。
然后调用跟踪如下:
calls expectedMockWarehouse{call("MockWarehouse()"),call("hasInventory(const std::string&, int) const", TALISKER,50),call("remove(const std::string&, int) const", TALISKER,50)
};
ASSERT_EQUAL(expectedMockWarehouse, allCalls[1]);
这里做了两件事:
- 定义了我们期望的调用序列(包括构造、调用的方法及参数)
- 断言实际调用与期望一致
优点总结
优点 | 说明 |
---|---|
简单直观 | Mock 实现靠真实 C++ 代码,减少宏和 DSL |
易于生成 | 借助 IDE 自动生成 Seam 和 Tracer |
调试友好 | 调用序列是清晰可比较的字符串列表 |
支持灵活匹配 | 可以用 std::regex 灵活匹配复杂调用 |
总结
Mockator 是对“做最简单的事情”的真实实现。
它放弃了复杂的 DSL、宏系统,转而使用 模板参数化 + 手动或自动生成类 + 调用追踪 的组合,使得:
- Mock 更像真实对象;
- 测试更可维护、更具可读性;
- 适合极限编程/迭代开发流程;
如果你倾向于简化 Mock 使用场景、希望避免框架引入的复杂性,Mockator 是很合适的选择。
这段内容是对 Mockator 框架中如何通过**调用顺序追踪(Sequencing)**进行 行为验证(Behavior Verification) 的一个详细展示。下面我来为你逐步讲解和整理这个流程,帮助你彻底理解。
背景知识:什么是行为验证 (Behavior Verification)
行为验证的核心目的是:
- 验证系统在特定输入下,是否按照预期顺序与依赖对象(DOC)交互。
- 不只关心结果(状态),还关心“怎么做的”。
例如:
EXPECT_CALL(warehouse, hasInventory(...)).WillOnce(...);
EXPECT_CALL(warehouse, remove(...));
这种方式就属于行为验证 —— 你期望系统在调用 remove()
之前会先调用 hasInventory()
。
Mockator 实现思路概述
Mockator 和传统的 GMock/Trompeloeil 不同,它:
- 使用
vector<call>
存储方法调用记录 - 利用
mock_id
区分每个 mock 实例 - 通过 全局
allCalls
向量 实现每个 mock 对象调用顺序的追踪 - 期望值定义为
calls expectedMockWarehouse{...}
,直接使用std::string
对方法名称+参数的匹配
代码逐段解析
初始化追踪系统
INIT_MOCKATOR();
static std::vector<calls> allCalls(1);
INIT_MOCKATOR()
:初始化调用追踪系统(宏,设置状态)allCalls(1)
:只追踪一个 mock 对象的调用
定义 MockWarehouse
struct MockWarehouse {size_t const mock_id;MockWarehouse(): mock_id(reserveNextCallId(allCalls)) {allCalls[mock_id].push_back(call("MockWarehouse()"));}bool hasInventory(std::string const& what, int howmany) const {allCalls[mock_id].push_back(call("hasInventory(const std::string&, int) const", what, howmany));return bool(); // 默认返回 false}void remove(std::string const& what, int howmany) const {allCalls[mock_id].push_back(call("remove(const std::string&, int) const", what, howmany));}
};
解释:
mock_id
:为每个 mock 对象分配唯一 ID(用于在 allCalls 中索引)- 所有方法都记录了自身的调用,包括名称和参数(作为字符串)
Cevelop IDE
会自动生成这段代码,你只需写业务测试逻辑
编写测试
MockWarehouse warehouse{};
OrderT<MockWarehouse> order(TALISKER, 50);
order.fill(warehouse);
ASSERT(not order.isFilled());
这部分是测试业务逻辑 —— 下单时库存为空,订单不应被填充。
定义并验证期望调用序列
calls expectedMockWarehouse{call("MockWarehouse()"),call("hasInventory(const std::string&, int) const", TALISKER, 50),call("remove(const std::string&, int) const", TALISKER, 50)
};
ASSERT_EQUAL(expectedMockWarehouse, allCalls[1]);
这里重点来了:
- 你定义了期望的调用序列
expectedMockWarehouse
是对 mock 的期望行为(包括调用的顺序)allCalls[1]
是实际发生的调用记录(注意索引1是分配给 mock 的)
通过ASSERT_EQUAL
来断言行为和顺序都一致。
核心理解总结
点 | 解释 |
---|---|
Mock 对象自动生成 | Mockator 借助 IDE(Cevelop)生成标准 C++ 类 |
方法调用记录为字符串 | 使用 std::string 表示每个调用及参数 |
支持顺序验证 | 通过 vector 顺序匹配实际调用与期望调用 |
非侵入式 | 不依赖宏或 DSL,代码更干净 |
简化行为验证 | 无需复杂 EXPECT_CALL,只需构造 expected calls |
补充可视化建议
如果你需要我画一个对应的调用序列图或类图来帮助你可视化整个过程(如 Order.fill(...)
调用了哪些 mock 方法、以什么顺序),我可以使用 Mermaid 来实现它。你需要吗?
例如:
这段话总结了Mocking Frameworks 的设计起源和特点,特别是 Java 生态中的 Mock 框架,比如早期的 JMock、EasyMock 以及 Google 的 GMock。下面是详细理解:
1. 设计起源
- 很多现代 Mock 框架设计理念,源自 JMock 和 EasyMock
这两者是比较早的 Java Mock 框架,后来 GMock(Google Mock,C++ Mock 框架)在设计上受到影响。
2. Java Mock 框架特点
- 基于反射(Reflection)实现
通过 Java 的反射机制动态创建 Mock 对象,动态调用方法。 - 只能 Mock 类和对象,依赖动态多态(动态绑定)
Java 中 Mock 依赖继承、接口实现、动态派发方法。 - 没有 Lambda 表达式(老版本)
早期版本没有 lambda,行为匹配和定义只能依赖反射及接口方法签名。 - 通过反射生成子类并重写方法实现 Mock 行为
框架会生成 Mock 类的子类,在调用方法时注入自定义逻辑。
3. 行为定义方式
- 使用 DSL(领域专用语言)描述行为,而非直接写普通代码
例如 JMock、EasyMock、GMock 都用类似的 Expectation/ExpectationSet DSL 来描述期望行为,调用顺序,参数匹配等。
4. 总结理解
- Mock 框架的核心是动态替换真实对象的行为,用于测试隔离。
- Java 里主要通过反射和动态代理实现,不依赖语言特性(lambda)。
- DSL 帮助测试者用简洁的语言声明 Mock 期望,方便行为验证。
总结了 C++ Mocking Frameworks(C++模拟测试框架)面临的典型问题,尤其是它们受限于C++语言特性,以及在设计上往往照搬Java框架的模式带来的不足。下面是详细理解:
C++ Mocking Frameworks 常见问题解析
1. 设计模仿 Java Mock 框架,缺乏利用 C++ 语言优势
- 许多C++模拟框架设计思路直接跟随了 JMock / EasyMock,这两者是基于 Java 的设计模式。
- 但 C++ 语言有其独特特性(如模板、强类型、无反射),简单套用 Java 方案会丢失 C++ 的优势。
2. 缺乏有用的反射机制
- Java 有强大的运行时反射,而 C++ 标准并没有(直到最近的标准才开始引入有限反射)。
- 因此 C++ 只能借助预处理宏(Macros)来辅助生成函数名、模拟函数定义。
3. 依赖子类化和虚函数,有时需要底层黑魔法
- C++ Mocking 主要通过继承虚函数类并重写函数实现 Mock 行为。
- 某些框架甚至使用未定义行为或者操作底层ABI(比如替换虚表vtable指针),来绕过限制(例:Hippomocks)。
- 这种做法存在移植性和稳定性风险。
4. 使用 DSL(基于宏的魔法)定义期望行为
- 行为定义用类似 Expectation 这样的 DSL(宏扩展),而非直接写纯 C++ 代码。
- 例如 EXPECT_CALL 宏链式调用设置调用次数、返回值等。
5. 行为匹配隐式发生在对象析构函数时
- 框架通常会在 Mock 对象析构时自动检查实际调用是否符合预期。
- 这种隐式校验对调试和理解测试流程带来一定难度。
6. 示例:EXPECT_CALL 宏的典型用法
EXPECT_CALL(turtle, GetY()).WillOnce(Return(100)).WillOnce(Return(200)).WillRepeatedly(Return(300));
- 表示对
GetY()
的调用,第一次返回100,第二次返回200,其后一直返回300。
7. 缺点总结
- fragile(脆弱):代码重构时 Mock 相关测试容易失效。
- 难复用:Mock 设置难以在不同测试间共享。
- 测试臃肿:测试代码膨胀,难维护。
总结
- C++ Mock 框架仍然被语言自身的限制和设计模式影响,面临灵活性和稳定性的挑战。
- 目前多依赖宏和继承虚函数模拟行为,带来不少副作用和复杂性。
- 随着C++反射等语言特性的逐步完善,未来 Mock 框架可能会更优雅。
这段内容主要讲了**“过度使用 Mock 的问题”**,尤其是它对测试设计和代码质量的负面影响。以下是详细理解:
Too much Mocking(过度 Mocking)问题点解析
1. 白盒测试导致测试代码、被测系统(SUT)和依赖对象(DOC)紧密耦合
- 测试依赖于内部实现细节,导致三者绑定过紧。
- 代码稍作修改就可能导致测试失败,难以维护。
2. 重构困难,设计灵活性丧失
- 测试代码和生产代码强耦合,重构工具难用,人工维护成本高。
- 设计灵活度受限,难以演进。
3. 促进状态化接口设计(Stateful APIs)
- 当使用 Mock 来测试 SUT 和 DOC 的交互时,常见设计会逐渐变成带状态的接口。
- 例如,Mock 对象暴露类似
setWiggle(Wiggle)
,setWaggle(Waggle)
,WiggleTheWaggle()
这类状态修改和操作接口。 - SUT 依赖调用它们的顺序,形成时序耦合(temporal coupling)。
4. 单参数或无参数函数和调用顺序依赖的趋势
- Mock 设计中容易出现无参数函数(niladic)或者单参数函数。
- 函数调用必须按特定顺序执行,增加测试复杂性。
5. Uncle Bob 的“Clean Code”被误解
- Uncle Bob 其实强调避免“时间耦合”,即避免代码依赖调用顺序。
- 但很多人误解为“鼓励无参数函数(niladic functions)”,反而导致更多时序依赖。
总结
- 过度 Mocking 会让测试变得脆弱且难以维护。
- 白盒测试如果过度关注细节,会丧失设计弹性和重构能力。
- 设计应避免时序依赖和状态化接口,保持模块独立和行为简单。
- 应正确理解“避免时间耦合”的意义,不是简单无参数函数,而是避免调用顺序带来的耦合。
你这段内容主要在说配置型测试替身(configurable test double)的问题,特别是使用类似 EXPECT_CALL 这种 DSL(领域专用语言)时带来的复杂性和混乱。这里我帮你梳理和总结理解,并用 Mermaid 逻辑图表现流程。
理解
问题核心
- DSL(如 EXPECT_CALL)用于配置 Mock 行为,写法复杂且混合了行为和期望,导致测试代码难以理解和维护。
- 例如:
这段代码混合了调用期望和返回值配置,写起来繁琐。EXPECT_CALL(turtle, GetY()).WillOnce(Return(100)).WillOnce(Return(200)).WillRepeatedly(Return(300));
- 可能伴随 ON_CALL 警告和隐式检查,增加调试难度。
测试替身(Test Double)的生命周期涉及的阶段:
- Setup (准备测试环境)
- Configuration (配置 Mock 行为和期望)
- Installation (装配 Mock 进 SUT)
- Exercise (执行 SUT 代码)
- Return Values (根据配置返回数据)
- Verify (校验期望是否达成)
- Teardown (清理)
Mermaid 逻辑图示例
各节点说明:
- Setup:准备测试环境,初始化对象
- Configuration:用 EXPECT_CALL 或 ON_CALL 设定 Mock 的行为和期望
- Installation:将 Mock 关联到被测系统(SUT)
- Exercise:运行测试操作,驱动 SUT 调用 Mock
- ReturnValues:Mock 根据配置返回指定数据
- Verify:检查调用是否符合预期(如调用次数、顺序、参数等)
- Teardown:清理测试环境,释放资源
你可以这样理解
- 复杂 DSL 语法把行为和期望混在一起,使测试脚本难维护。
- 测试替身配置是整个测试流程中间关键且复杂的一环。
- 这个模型帮助拆解和理清测试替身的使用步骤。
这部分强调了**紧耦合(tight coupling)**带来的风险:
- 测试代码和被测代码(SUT)之间耦合过紧,会导致:
- 重构困难:改动 SUT 代码可能导致大量测试失败,维护成本变高。
- 测试脆弱:测试对内部实现细节依赖太多,稍微变动接口或行为就可能破坏测试。
- 这就是所谓的“GLUE”——测试代码和生产代码之间过度粘合,失去灵活性。
可以用一句话总结:
过度 Mock 导致测试、SUT 和依赖对象(DOC)之间的紧耦合,使得重构成本变高,测试变得脆弱。
如果用Mermaid图来表达“紧耦合”的危害,可能是这样:
Stateful APIs are bad?!
这部分讲的是有状态API(Stateful APIs)的问题:
核心点:
- 有状态API指的是必须按照特定顺序调用多个方法,维护内部状态的API。比如图示的流程:
acquire
→use
→release
,并且use
可以循环多次。
- **RAII(资源获取即初始化)**模式是OK的,因为资源的获取和释放是自动且绑定生命周期的,比如C++的智能指针:
- 只要对象生命周期结束,资源自动释放,避免忘记释放。
- 如果没有RAII,状态管理就是“坏”的:
- 用户可能会忘记调用释放,导致资源泄漏。
- 复杂的状态机使得接口难以正确使用,也难以测试。
- 典型有状态API示例:
- 文件操作:
open
→read/write
→close
- 网络操作:
socket
→bind
→listen
→accept
→close
- 文件操作:
结论:
- 简单的状态转换且自动管理(如RAII)是可以接受的。
- 复杂且需要显式管理状态的API容易出错且难以测试。
这段内容强调了Bad Stateful APIs 的具体案例和现实中的复杂性,重点如下:
Bad Stateful APIs:套接字(Sockets)
- 传统Unix资源API(文件)是单步初始化 - 操作 - 关闭,结构简单。
- BSD套接字API很复杂,必须进行多阶段初始化:
- 例如:
socket
→bind
→listen
→accept
→read/write
→close
- 或者:
socket
→bind
(可选)→connect
→read/write
→close
- 例如:
- 多语言中大量包装库仍保留这种多步骤初始化方式。
- 建议:自己写库时不要这样设计,多步骤状态管理难用且易错!
Stateful APIs 是真坏吗?
- 许多图形库也是状态驱动的(如Turtle图形,Cairo库)。
- 它们通常用一长串“setter”调用,最后执行动作。
- 多步初始化常常未封装,导致使用复杂。
- 小改动可能引发大问题,代码难理解。
- 好在通常有合理的默认值缓解了部分问题。
例子:Cairo 图形库代码片段
cairo_text_extents_t te;
cairo_set_source_rgb(cr, 0.0, 0.0, 0.0);
cairo_select_font_face(cr, "Georgia",CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_BOLD);
cairo_set_font_size(cr, 1.2);
cairo_text_extents(cr, "a", &te);
cairo_move_to(cr, 0.5 - te.width / 2 - te.x_bearing,0.5 - te.height / 2 - te.y_bearing);
cairo_show_text(cr, "a");
- 这里可以看到很多set操作设置状态,最后才有绘制动作。
总结:
- 多步状态ful API 确实难用且难测,尤其当多阶段初始化没封装时。
- 但是在某些领域(图形库)这种风格广泛存在,合理默认值和封装能减轻痛苦。
- 设计时应尽量简化状态管理,减少多步初始化,或封装成单步调用。
这段内容围绕序列化调用和什么时候用序列匹配器(sequence matchers),还有针对Legacy代码和Mocking的总结,重点如下:
需要序列化调用的场景(Sequencing)
- 代码示例:
last(third(second(first(something))));
- 通过将前一步的结果传给下一步实现序列调用
- 注意C++中函数参数的未定义顺序求值,可能导致意外问题
- 封装逻辑序列:
- 利用类构造函数(ctors)
- 命名函数封装序列
- 设计权衡:
- 泛化和灵活性 vs. 可理解性和维护性
- 小心“编程巧合”(Programming by coincidence)
什么时候使用序列匹配器(sequence matchers)
- 当你面临有状态且依赖调用顺序的、不能改动的第三方API/DOC
- 不能用无状态的Facade包装它
- 例如:正在构建对这个API的包装器
- 也可能是设计模式,比如Builder模式
总结
- 测试遗留代码时,最好先引入Seams(测试接口)和Stub DOC
- 利用C++和IDE的力量简化测试代码
- 只有在必须测试无法改变的有状态API时才用Mocks
- 警惕mock框架给新代码设计带来的坏影响
- 不要用mock框架模拟还没写好的代码(防止过早设计)
- 记住KISS原则(Keep It Simple, Stupid)也适用于自动化测试